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