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 thatState
new one is not passed to closure.Creates new instance of
MutableState
but previous instance exists insidepointerInput
.You need to set same keys to
pointerInput(key1 = maxWidth, key2 = maxHeight)
to reset it so it will capture the currentMutableState
assigned tocamera2D
after remember is reset.Explained in detail how
LaunchedEffect
,DisposableEffect
or which both areremember
under the hood, orpointerInput
capture aState
and don't get new one unless you reset these withkeys
.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