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 -
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".
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?
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.