1

I have a composable which uses Canvas to draw tasks on a clock-like dial. The tasks are read from a ViewModel state, which in turn loads them from a database. The list of tasks varies depending on the selected date. When you choose a date (e.g. 26th June) on a calendar component, the ViewModel loads the tasks for 26th June and draws them (task drawing not shown in the example below).

When I tap the Canvas, the log shows data for the 26th June. If I select a new date (e.g. 27th June) the tasks for the 27th June are loaded properly.

However, when I tap the dial on a 27th June, the data from the 26th June (i.e. the previous date) is loaded again for some reason behind the scenes. The Logcat logs show 26th June data (initial loading of data), 26th June data (after tapping the Canvas), 27th June data (after selecting the 27th June), and then again 26th June data (after tapping the Canvas) even though I haven't selected this date again - the dial was definitely drawn with the correct data though (for 27th June).

The fact that the data goes back to the 26th June is a problem. I expect it to stay on the 27th June until I select a different date. This going-back-of-data-to-previous-date problem happens only when I tap the dial and then select a different date. If I keep selecting different dates without tapping the dial in between date selection, the data doesn't go back to previous date on its own.

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import kotlin.math.min
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import com.example.circularplanner.ui.viewmodel.DayState
import com.example.circularplanner.ui.viewmodel.TaskUiState
import com.example.circularplanner.ui.viewmodel.UserInput
import com.example.circularplanner.utils.TaskDialUtils.square
import java.time.LocalDate
import kotlin.math.sqrt

@Composable
fun TestTaskDial(
    dayState: DayState,
    userInput: UserInput,
    onSetDate: (LocalDate) -> Unit
) {
    // The width and height of the Canvas
    var width by remember { mutableStateOf(0) }
    var height by remember { mutableStateOf(0) }
    var touchInsideTheDial by remember { mutableStateOf(false) }
    var center by remember { mutableStateOf(Offset.Zero) }

    // Basically the width of the finger touch on the screen
    var touchStroke: Float = 50f

    // The radius of the dial (from the center to the end of the clock steps)
    var outerRadius by remember { mutableStateOf(0f) }

    Log.i("DDselectedDate", userInput.selectedDate.toString())
    Log.i("DDdate", dayState.date.toString())
    Log.i("DDtasks", dayState.tasks.joinToString())

    fun distance(first: Offset, second: Offset): Float {
        return sqrt((first.x - second.x).square() + (first.y - second.y).square())
    }

    fun checkIfTouchInsideDial(distance: Float, radius: Float, touchStroke: Float): Boolean {
        if (distance <= radius + touchStroke * 2f) {
            return true
        } else {
            return false
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
    ) {
        Canvas (
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.8f)
                .onGloballyPositioned {
                    width = it.size.width
                    height = it.size.height
                    center = Offset(width / 2f, height / 2f)
                    // The radius of the dial
                    outerRadius = min(width.toFloat(), height.toFloat()) / 2f
                }
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = { offset ->
                            val distance = distance(offset, center)
                            touchInsideTheDial = checkIfTouchInsideDial(
                                distance,
                                outerRadius,
                                touchStroke
                            )

                            if (touchInsideTheDial) {
                                Log.i("TaskDial", "onTap")
                                Log.i("DDselectedDate", userInput.selectedDate.toString())
                                Log.i("DDdate", dayState.date.toString())
                                Log.i("DDtasks", dayState.tasks.joinToString())
                            }
                        }
                    )
                }
        ) {

            outerRadius = min(width, height) / 2f * 0.8f

            // Draw dial
            drawCircle(
                color = Color.Cyan,
                center = center,
                radius = outerRadius,
            )
        }

        Row(
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.1f),
            verticalAlignment = Alignment.Bottom,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Button(onClick = { onSetDate(LocalDate.parse("2025-06-26")) }) {
                Text("26th June")
            }
            Button(onClick = { onSetDate(LocalDate.parse("2025-06-27")) }) {
                Text("27th June")
            }
        }

        Row (
            modifier = Modifier
                .fillMaxWidth()
                .fillMaxHeight(0.1f),
            verticalAlignment = Alignment.Bottom,
            horizontalArrangement = Arrangement.Center
        ) {
            Text(text = userInput.selectedDate.toString())
        }
    }
}
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.example.circularplanner.data.Task
import com.example.circularplanner.data.Time
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import java.time.LocalDate
import java.util.UUID
import kotlin.String

typealias Tasks = List<Task>
data class DayState (
    val date: LocalDate = LocalDate.now(),
    val activeTimeStart: Time = Time(6, 0),
    val activeTimeEnd: Time = Time(22, 0),
    val tasks: Tasks = emptyList()
)

data class UserInput(
    val selectedDate: LocalDate = LocalDate.parse("2025-06-26"),
)

data class Day(
    val date: LocalDate,
    val activeTimeStart: Time,
    val activeTimeEnd: Time,
)

data class Time(
    var hour: Int = 0,
    var minute: Int = 0
)

data class Task (
    val date: LocalDate,
    var title: String,
    var startTime: Time,
    var endTime: Time,
    var description: String = "",
    val id: UUID = UUID.randomUUID()
)

class TestViewModel() : ViewModel() {
    companion object {
        private const val MILLS = 5_000L

        val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
            @Suppress("UNCHECKED_CAST")
            override fun <T : ViewModel> create(
                modelClass: Class<T>,
                extras: CreationExtras
            ): T {
                return TestViewModel() as T
            }
        }
    }

    val days = mapOf(
        LocalDate.parse("2025-06-26") to Day(
            date = LocalDate.parse("2025-06-26"),
            activeTimeStart = Time(6,0),
            activeTimeEnd = Time(22,0)
        ),
        LocalDate.parse("2025-06-27") to Day(
            date = LocalDate.parse("2025-06-27"),
            activeTimeStart = Time(6,0),
            activeTimeEnd = Time(20,30)
        )
    )
    val tasks = mapOf(
        LocalDate.parse("2025-06-26") to listOf(
            Task(
                date = LocalDate.parse("2025-06-26"),
                title = "Bis Dahin",
                startTime = Time(11,3),
                endTime = Time(13,0),
                description = "Willkommen bei Gboard! Texte, die Sie kopieren, werden hier gespeichert.",
                id = UUID.fromString("b8d9b09b-d18a-4e00-a347-9d388b5c779a")
            ),
            Task(
                date = LocalDate.parse("2025-06-26"),
                title = "No Let's Go",
                startTime = Time(14,56),
                endTime = Time(16,55),
                description = "Programming Android with Kotlin",
                id = UUID.fromString("c68ee93c-7100-4020-9006-c48b8402488d")
            )
        ),
        LocalDate.parse("2025-06-27") to listOf(
            Task(
                date = LocalDate.parse("2025-06-27"),
                title = "Hallo Herr Schmidt",
                startTime = Time(15,34),
                endTime = Time(20,0),
                description = "Willkommen bei Gboard! Texte, die Sie kopieren, werden hier gespeichert.",
                id = UUID.fromString("14873472-8bb4-43a8-9f5d-a0265a5f8eda")
            )
        )
    )

    val userInput = MutableStateFlow(UserInput())

    @OptIn(ExperimentalCoroutinesApi::class)
    val dayState = userInput.flatMapLatest { input: UserInput ->
        Log.i("TaskViewModel", "loading dayState")

        val dayFlow = flow {
            emit(days.get(input.selectedDate))
        }
        val tasksFlow = flow {
            emit(tasks.get(input.selectedDate))
        }

        combine(
            flow = dayFlow,
            flow2 = tasksFlow
        ) { day, tasks ->
            DayState(
                date = input.selectedDate,
                activeTimeStart = day?.activeTimeStart ?: Time(6, 0),
                activeTimeEnd = day?.activeTimeEnd ?: Time(22, 0),
                tasks = tasks!!
            )
        }
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L),
            initialValue = DayState()
        )

    fun setSelectedDate(date: LocalDate) {
        Log.i("setSelectedDate", date.toString())

        userInput.update {
            it.copy(
                selectedDate = date
            )
        }
    }
}

1 Answer 1

1

The issue is how you use pointerInput.

The pointerInput's lambda captures all variables that are used by it. The lambda will only be restarted when one of the keys you provided change - which never happens because you only specified Unit. Therefore userInput and dayState will always be the same, entirely unaffected by whatever date is selected.

The documentation explains the two possible solutions to this. The first one is what you need, i.e. you need to specify the two variables as keys, instead of Unit:

.pointerInput(userInput, dayState) {
    // ...
}

The second solution only works for local variables: Hide the object behind a delegate. This is what you already do with center, touchInsideTheDial and outerRadius. They are not really Offset, Boolean and Float respectively because due to the by delegation they are actually resolved by the compiler to MutableStates. And everytime they are used the getValue() method is called to finally retrieve the Offset, Boolean and Float. Bottom line, the lambda captures MutableStates here, but since they stay the same MutableStates all the time that doesn't pose a problem. Only their values change. And everytime the lambda is executed the implicit getValue() call always retrieves the current value at this time.

That's the reason you do not need to add these as keys too.

The only variable left that is used in the lambda is touchStroke. It is never changed and will always be 50f, therefore it doesn't matter if it is correctly captured. But since you declared it as var and not val this may change in the future, so you should already make this work correctly for pointerInput by either adding it as the third key or by wrapping the value in a delegated MutableState (like center, touchInsideTheDial and outerRadius).

Sign up to request clarification or add additional context in comments.

1 Comment

You're right, specifying userInput and dayState as keys for the .pointerInput() method solved the problem. Thank you

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.