androidkotlinandroid-jetpack-composecompose-recompositionandroid-jetpack-compose-gesture

Composable not recomposing on Android API 26 (Oreo) after changing the value of a MutableState


When I run the code present at the end of the question on devices with different Android API levels, the app behaves differently.

Device 1: Android API 33 (Android 13)
The code works as expected. The value of camera2D is changed and the composable is recomposed.

Device 2: Android API 26 (Android Oreo)
The code does not work as expected. The value of camera2D is changed but the composable is NOT recomposed.

Code:

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp

@Composable
fun EditorScreen() {
    BoxWithConstraints {
        val dpToPxRatio = with(LocalDensity.current) {
            1.dp.toPx()
        }

        val camera2D = remember(key1 = maxWidth, key2 = maxHeight) {
            mutableStateOf(
                Camera2D(
                    translation = Translation2D(
                        x = (maxWidth.value * dpToPxRatio) / 2f,
                        y = (maxHeight.value * dpToPxRatio) / 2f
                    ),
                    scale = Scale2D(
                        scaleX = 1f,
                        scaleY = 1f
                    )
                )
            )
        }

        val editorObjects = remember {
            mutableStateListOf(
                SquareEditorObject2D(
                    translation = Translation2D(x = 0.0f, y = 0.0f),
                    size = Size2D(width = 350.0f, height = 580.0f),
                    scale = Scale2D(scaleX = 1.0f, scaleY = 1.0f),
                    rotation = Rotation2D(zRotationDegrees = 0f),
                    color = Color.Blue
                ),
                SquareEditorObject2D(
                    translation = Translation2D(x = 0.0f, y = 0.0f),
                    size = Size2D(width = 350.0f, height = 580.0f),
                    scale = Scale2D(scaleX = 1.0f, scaleY = 1.0f),
                    rotation = Rotation2D(zRotationDegrees = 45f),
                    color = Color.Green
                ),
                SquareEditorObject2D(
                    translation = Translation2D(x = 0.0f, y = 0.0f),
                    size = Size2D(width = 350.0f, height = 580.0f),
                    scale = Scale2D(scaleX = 1.0f, scaleY = 1.0f),
                    rotation = Rotation2D(zRotationDegrees = 135f),
                    color = Color.Red
                )
            )
        }
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(key1 = Unit) {
                    awaitEachGesture {
                        awaitFirstDown()

                        do {
                            val currentCamera2D = camera2D.value

                            val event = awaitPointerEvent()
                            val pointerCount = event.changes.count()
                            if (pointerCount == 1) {
                                val dragAmount = event.calculatePan()

                                camera2D.value = currentCamera2D.copy(
                                    translation = Translation2D(
                                        x = currentCamera2D.translation.x + dragAmount.x,
                                        y = currentCamera2D.translation.y + dragAmount.y
                                    )
                                )
                            } else if (pointerCount > 1) {
                                val eventZoom = event.calculateZoom()

                                camera2D.value = currentCamera2D.copy(
                                    scale = Scale2D(
                                        scaleX = currentCamera2D.scale.scaleX * eventZoom,
                                        scaleY = currentCamera2D.scale.scaleY * eventZoom
                                    )
                                )
                            }
                        } while (event.changes.any { it.pressed })
                    }
                }
        ) {
            withTransform(
                {
                    translate(
                        left = camera2D.value.translation.x,
                        top = camera2D.value.translation.y
                    )
                    scale(
                        scaleX = camera2D.value.scale.scaleX,
                        scaleY = camera2D.value.scale.scaleY
                    )
                }
            ) {
                for (editorObject in editorObjects) {
                    withTransform(
                        {
                            translate(
                                left = editorObject.translation.x - editorObject.pivot.x,
                                top = editorObject.translation.y - editorObject.pivot.y
                            )
                            rotate(
                                degrees = editorObject.rotation.zRotationDegrees,
                                pivot = Offset(
                                    x = editorObject.size.width / 2f,
                                    y = editorObject.size.height / 2f
                                )
                            )
                            scale(
                                scaleX = editorObject.scale.scaleX,
                                scaleY = editorObject.scale.scaleY,
                                pivot = Offset(
                                    x = editorObject.size.width / 2f,
                                    y = editorObject.size.height / 2f
                                )
                            )
                        }
                    ) {
                        drawRect(
                            color = editorObject.color,
                            size = Size(
                                width = editorObject.size.width,
                                height = editorObject.size.height
                            )
                        )
                    }
                }
            }
        }
    }
}

data class Translation2D(val x: Float, val y: Float)

data class Size2D(val width: Float, val height: Float)

data class Scale2D(val scaleX: Float, val scaleY: Float)

data class Rotation2D(val zRotationDegrees: Float)

data class SquareEditorObject2D(
    val translation: Translation2D = Translation2D(x = 0f, y = 0f),
    val size: Size2D,
    val scale: Scale2D = Scale2D(scaleX = 1f, scaleY = 1f),
    val rotation: Rotation2D = Rotation2D(zRotationDegrees = 0f),
    val color: Color
) {

    val pivot = Translation2D(
        x = (size.width / 2f),
        y = (size.height / 2f)
    )
}

data class Camera2D(
    val translation: Translation2D = Translation2D(x = 0f, y = 0f),
    val scale: Scale2D = Scale2D(scaleX = 1f, scaleY = 1f),
    val rotation: Rotation2D = Rotation2D(zRotationDegrees = 0f)
)

Libraries Versions:

implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.7.2")
implementation(platform("androidx.compose:compose-bom:2023.03.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")

Solution

  • The issue happens when closures capture a State and when you create new instance of that State new one is not passed to closure.

     val camera2D = remember(key1 = maxWidth, key2 = maxHeight) {
                mutableStateOf(
                    Camera2D(
                        translation = Translation2D(
                            x = (maxWidth.value * dpToPxRatio) / 2f,
                            y = (maxHeight.value * dpToPxRatio) / 2f
                        ),
                        scale = Scale2D(
                            scaleX = 1f,
                            scaleY = 1f
                        )
                    )
                )
            }
    

    Creates new instance of MutableState but previous instance exists inside pointerInput.

    You need to set same keys to pointerInput(key1 = maxWidth, key2 = maxHeight) to reset it so it will capture the current MutableState assigned to camera2D after remember is reset.

    Explained in detail how LaunchedEffect, DisposableEffect or which both are remember under the hood, or pointerInput capture a State and don't get new one unless you reset these with keys.

    Value of MutableState inside Modifier.pointerInput doesn't change after remember keys updated

    https://stackoverflow.com/a/73610519/5457853

    Also check last part of this answer where you can see new instance is not stored inside lambda of LaunchedEffect.

    https://stackoverflow.com/a/77321291/5457853