androidkotlinandroid-jetpack-composeandroid-viewmodelcomposable

Android Studio - Jetpack Compose Kotlin circular scroller input time selection


Hi I'm new to android and I've been trying to implement a circular scroller for time selection as an input field in a form -

reference image

I am having a weird issue where the index value keeps changing by +1 after selection. For example if I select "1:05 PM", the selected value ends up changing to "2:06 AM".

example 1 example 2

I'm not sure why but I can see by logging the index change and value change it is firing several times.

This is what I have so far (stripped of irrelevant parts) -

Main composable:

@Composable
fun AddNewScreen(viewModel: SomeViewModel) {
    val timeViewModel: TimeViewModel = viewModel()
    var showStartTime by remember {
        mutableStateOf(false)
    }

    var selectedStartTimeObj = viewModel.startTime.observeAsState()
    var selectedStartTime = selectedStartTimeObj.value

    Column(
        modifier = Modifier
            .pointerInput(Unit) {
                detectTapGestures(onTap = {
                    if (showStartTime) {
                        showStartTime = false
                    }
                })
            }
            .verticalScroll(rememberScrollState()),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {

        if (selectedStartTime != null) {
            TimeScrollInput(
                showStartTime,
                toggleShow = { showStartTime = it },
                selectedStartTime,
                onSelected = { viewModel.updateTime(true, it) },
                timeViewModel
            )
        }

    }
}

Lower level composables:

 @Composable
    fun TimeScrollInput(
        show: Boolean = false,
        toggleShow: (Boolean) -> Unit,
        selectedTime: Time,
        onSelected: (Time) -> Unit,
        viewModel: TimeViewModel
    ) {
        if (show) {
            selectedTime?.let { it ->
                TimeSelectionScrollerStart(
                    time = it,
                    timeSelection = {
                        onSelected(it)
                    },
                    viewModel
                )
            }
        }
        else {
            Text(
                selectedTime.hour + ":" + selectedTime.minute + " " + selectedTime.meridiem,
                modifier = Modifier.clickable {
                    toggleShow(true)
                })
        }
    }


    @Composable
    fun TimeSelectionScrollerStart(
        time: Time,
        timeSelection: (Time) -> Unit,
        timeViewModel: TimeViewModel
    ) {
    
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.Center
        ) {
    
            InfiniteCircularList(
                width = 30.dp,
                itemHeight = 40.dp,
                items = timeViewModel.hours,
                initialItem = time.hour,
                textColor = Color.Gray,
                selectedTextColor = colorResource(R.color.violet)
            ) { itemSelected ->
                timeSelection(Time(itemSelected, time.minute, time.meridiem))
            }
    
            Spacer(modifier = Modifier.width(4.dp))
    
            Text(":")
    
            Spacer(modifier = Modifier.width(4.dp))
    
            InfiniteCircularList(
                width = 30.dp,
                itemHeight = 40.dp,
                items = timeViewModel.minutes,
                initialItem = time.minute,
                textColor = Color.Gray,
                selectedTextColor = colorResource(R.color.violet)
            ) { itemSelected ->
                timeSelection(Time(time.hour, itemSelected, time.meridiem))
            }
    
            Spacer(modifier = Modifier.width(6.dp))
    
            InfiniteCircularList(
                width = 30.dp,
                itemHeight = 40.dp,
                items = timeViewModel.meridiem,
                initialItem = time.meridiem,
                textColor = Color.Gray,
                selectedTextColor = colorResource(R.color.violet)
            ) { itemSelected ->
                timeSelection(Time(time.hour, time.minute, itemSelected))
            }
    
        }
    }

Circular scroller composable template (code I found when searching for a solution.):

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> InfiniteCircularList(
    width: Dp,
    itemHeight: Dp,
    numberOfDisplayedItems: Int = 3,
    items: List<T>,
    initialItem: T,
    textColor: Color,
    selectedTextColor: Color,
    onItemSelected: (index: Int, item: T) -> Unit = { _, _ -> },
    itemSelected: (String) -> Unit
) {
    val itemHalfHeight = LocalDensity.current.run { itemHeight.toPx() / 2f }
    val scrollState = rememberLazyListState(0)
    var lastSelectedIndex by rememberSaveable {
        mutableIntStateOf(0)
    }
    var itemsState by remember {
        mutableStateOf(items)
    }

    LaunchedEffect(Unit) {
        var targetIndex = items.indexOf(initialItem) - 1
        targetIndex += ((Int.MAX_VALUE / 2) / items.size) * items.size
        itemsState = items
        lastSelectedIndex = targetIndex
        scrollState.scrollToItem(targetIndex)
        Log.d(TAG, "launch effect last selected index, $lastSelectedIndex")
        Log.d(TAG, "launch effect target index, $targetIndex")
    }

    LazyColumn(
        modifier = Modifier
            .width(width)
            .height(itemHeight * numberOfDisplayedItems),
        state = scrollState,
        flingBehavior = rememberSnapFlingBehavior(
            lazyListState = scrollState
        )
    ) {
        items(
            count = Int.MAX_VALUE,
            itemContent = { i ->
                val item = itemsState[i % itemsState.size]
                Log.d(TAG, "item val first initialize, item = $item")
                Box(
                    modifier = Modifier
                        .height(itemHeight)
                        .fillMaxWidth()
                        .onGloballyPositioned { coordinates ->
                            val y = coordinates.positionInParent().y - itemHalfHeight
                            val parentHalfHeight = (itemHalfHeight * numberOfDisplayedItems)
                            val isSelected =
                                (y > parentHalfHeight - itemHalfHeight && y < parentHalfHeight + itemHalfHeight)
                            val index = i - 1
                            if (isSelected && lastSelectedIndex != index) {
                                Log.d(TAG, "before setting onItemSelected, item = $item")
                                onItemSelected(index % itemsState.size, item)
                                Log.d(TAG, "before setting itemSelected, item = $item")
                                itemSelected(item.toString())
                                lastSelectedIndex = index
                            }
                        },
                    contentAlignment = Alignment.Center
                ) {

                    Text(
                        text = item.toString(),
                        color = if (lastSelectedIndex == i) {
                            selectedTextColor
                        } else {
                            textColor
                        },
                        fontSize = if (lastSelectedIndex == i) {
                            16.sp
                        } else {
                            14.sp
                        }
                    )
                }
            }
        )
    }
}

"SomeViewModel":

class SomeViewModel : ViewModel() {

private val _startTime = MutableLiveData<Time>(Time("12", "00", "AM"))
var startTime: LiveData<Time> = _startTime

fun updateTime(start: Boolean, time: Time) {
        Log.d(TAG, "Updating time!! - $time")
        if(start) {
            _startTime.value = time
        }
        else {
            //_endTime.value = time
        }
    }
}

TimeViewModel:

class TimeViewModel : ViewModel() {
    val hours =
        mutableStateListOf<String>()
            .apply {
                IntStream.rangeClosed(1, 12)
                    .forEach { hr ->
                        add(hr.toString())
                    }
            }

    val minutes =
        mutableStateListOf<String>()
            .apply {
                IntStream.rangeClosed(0, 59)
                    .forEach { min ->
                        if (min < 10) {
                            add(min.toString().padStart(2, '0'))
                        } else {
                            add(min.toString())
                        }
                    }
            }

    val meridiem =
        mutableStateListOf<String>()
            .apply {
                add("AM")
                add("PM")
            }
}

Time() data class:

data class Time(
    val hour : String = "12",
    val minute : String = "00",
    val meridiem : String = "PM"
)

Any ideas on why this is happening?


Solution

  • There is an error in InfiniteCircularList implementation. It returns the item at index i % itemsState.size but should return one at index (i - 1) % itemsState.size. To fix replace the line:

    itemSelected(item.toString())
    

    with:

    val selectedItem = itemsState[index % itemsState.size]
    itemSelected(selectedItem.toString())
    

    Same goes for onItemSelected, if you need it.