diff --git a/CHANGELOG.md b/CHANGELOG.md index e3911463d..4b0e9015e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#579](https://github.com/bumble-tech/appyx/pull/579) – Expose `AndroidLifecycle` in `PlatformLifecycleRegistry` for Android - [#584](https://github.com/bumble-tech/appyx/pull/584) – Fix applying offset twice in `AppyxComponent` +- [#585](https://github.com/bumble-tech/appyx/pull/585) – Fix drag vs align ## 2.0.0-alpha04 diff --git a/appyx-components/experimental/cards/android/src/main/kotlin/com/bumble/appyx/components/experimental/cards/android/DatingCards.kt b/appyx-components/experimental/cards/android/src/main/kotlin/com/bumble/appyx/components/experimental/cards/android/DatingCards.kt index 191495aca..600b25846 100644 --- a/appyx-components/experimental/cards/android/src/main/kotlin/com/bumble/appyx/components/experimental/cards/android/DatingCards.kt +++ b/appyx-components/experimental/cards/android/src/main/kotlin/com/bumble/appyx/components/experimental/cards/android/DatingCards.kt @@ -49,11 +49,10 @@ fun DatingCards(modifier: Modifier = Modifier) { appyxComponent = cards, gestureValidator = permissiveValidator, ) { elementUiModel -> - Box( - modifier = elementUiModel.modifier - ) { - ProfileCard(profile = elementUiModel.element.interactionTarget.profile) - } + ProfileCard( + profile = elementUiModel.element.interactionTarget.profile, + modifier = Modifier.fillMaxSize().then(elementUiModel.modifier) + ) } } diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt index dc82db6b0..72c40b3b3 100644 --- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt +++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round @@ -40,6 +42,10 @@ import com.bumble.appyx.interactions.core.ui.LocalMotionProperties import com.bumble.appyx.interactions.core.ui.context.TransitionBounds import com.bumble.appyx.interactions.core.ui.context.UiContext import com.bumble.appyx.interactions.core.ui.output.ElementUiModel +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside.Value +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionOutside +import com.bumble.appyx.interactions.core.ui.property.motionPropertyRenderValue private val defaultExtraTouch = 48f.dp @@ -70,6 +76,7 @@ fun DraggableAppyxComponent( val elementUiModels by appyxComponent.uiModels.collectAsState() val coroutineScope = rememberCoroutineScope() val gestureExtraTouchAreaPx = with(density) { gestureExtraTouchArea.toPx() } + var containerSize by remember { mutableStateOf(IntSize.Zero) } var uiContext by remember { mutableStateOf(null) } LaunchedEffect(uiContext) { @@ -80,6 +87,7 @@ fun DraggableAppyxComponent( .fillMaxSize() .then(if (clipToBounds) Modifier.clipToBounds() else Modifier) .onPlaced { + containerSize = it.size uiContext = UiContext( coroutineScope = coroutineScope, transitionBounds = TransitionBounds( @@ -97,80 +105,97 @@ fun DraggableAppyxComponent( appyxComponent.onRelease() } } - .fillMaxSize() ) { CompositionLocalProvider(LocalBoxScope provides this) { - elementUiModels - .forEach { elementUiModel -> - key(elementUiModel.element.id) { - var transformedBoundingBox by remember(elementUiModel.element.id) { - mutableStateOf(Rect.Zero) - } - var size by remember(elementUiModel.element.id) { mutableStateOf(IntSize.Zero) } - var offsetCenter by remember(elementUiModel.element.id) { - mutableStateOf( - Offset.Zero - ) - } - val isVisible by elementUiModel.visibleState.collectAsState() - elementUiModel.persistentContainer() - if (isVisible) { - Box( - modifier = Modifier - .offset { offsetCenter.round() } - .width(with(density) { size.width.toDp() }) - .height(with(density) { size.height.toDp() }) - .pointerInput(appyxComponent) { - detectDragGesturesOrCancellation( - onDragStart = { position -> - appyxComponent.onStartDrag(position) - }, - onDrag = { change, dragAmount -> - if (gestureValidator.isGestureValid( - change.position, - transformedBoundingBox.translate(-offsetCenter) - ) - ) { - change.consume() - appyxComponent.onDrag(dragAmount, density) - true - } else { + elementUiModels.forEach { elementUiModel -> + val id = elementUiModel.element.id + + key(id) { + var transformedBoundingBox by remember(id) { mutableStateOf(Rect.Zero) } + var elementSize by remember(id) { mutableStateOf(IntSize.Zero) } + var offsetCenter by remember(id) { mutableStateOf(Offset.Zero) } + val isVisible by elementUiModel.visibleState.collectAsState() + + elementUiModel.persistentContainer() + + if (isVisible) { + CompositionLocalProvider( + LocalMotionProperties provides elementUiModel.motionProperties + ) { + val elementOffset = offsetCenter.round() - elementOffset(elementSize, containerSize) + + element.invoke( + elementUiModel.copy( + modifier = Modifier + .offset { elementOffset } + .width(with(density) { elementSize.width.toDp() }) + .height(with(density) { elementSize.height.toDp() }) + .pointerInput(appyxComponent) { + detectDragGesturesOrCancellation( + onDragStart = { position -> + appyxComponent.onStartDrag(position) + }, + onDrag = { change, dragAmount -> + if (gestureValidator.isGestureValid( + change.position, + transformedBoundingBox.translate(-offsetCenter) + ) + ) { + change.consume() + appyxComponent.onDrag(dragAmount, density) + true + } else { + appyxComponent.onDragEnd() + false + } + }, + onDragEnd = { appyxComponent.onDragEnd() - false - } - }, - onDragEnd = { - appyxComponent.onDragEnd() - }, - ) - } - ) - CompositionLocalProvider( - LocalMotionProperties provides elementUiModel.motionProperties - ) { - element.invoke( - elementUiModel.copy( - modifier = Modifier - .then(elementUiModel.modifier) - .onPlaced { - size = it.size - val localCenter = Offset( - it.size.width.toFloat(), - it.size.height.toFloat() - ) / 2f + }, + ) + } + .offset { -elementOffset } + .then(elementUiModel.modifier) + .onPlaced { + elementSize = it.size + val localCenter = Offset( + it.size.width.toFloat(), + it.size.height.toFloat() + ) / 2f - transformedBoundingBox = - it.boundsInParent() - .inflate(gestureExtraTouchAreaPx) - offsetCenter = - transformedBoundingBox.center - localCenter - } - ) + transformedBoundingBox = + it.boundsInParent().inflate(gestureExtraTouchAreaPx) + offsetCenter = + transformedBoundingBox.center - localCenter + } ) - } + ) } } } + } } } } + + +@Composable +fun elementOffset( + elementSize: IntSize, + containerSize: IntSize, +): IntOffset { + + val positionInside = motionPropertyRenderValue() + val positionOutside = motionPropertyRenderValue() + val layoutDirection = LocalLayoutDirection.current + + val positionInsideOffset = positionInside?.let { + it.alignment.align(elementSize, containerSize, layoutDirection) + } ?: IntOffset.Zero + + val positionOutsideOffset = positionOutside?.let { + it.alignment.align(elementSize, containerSize, layoutDirection) + } ?: IntOffset.Zero + + return positionInsideOffset + positionOutsideOffset +} diff --git a/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/AppyxComponent.kt b/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/AppyxComponent.kt index 4aaf780cb..6125a06a1 100644 --- a/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/AppyxComponent.kt +++ b/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/AppyxComponent.kt @@ -2,9 +2,7 @@ package com.bumble.appyx.navigation.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -25,7 +23,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round @@ -35,9 +35,14 @@ import com.bumble.appyx.interactions.core.model.BaseAppyxComponent import com.bumble.appyx.interactions.core.model.removedElements import com.bumble.appyx.interactions.core.modifiers.onPointerEvent import com.bumble.appyx.interactions.core.ui.LocalBoxScope +import com.bumble.appyx.interactions.core.ui.LocalMotionProperties import com.bumble.appyx.interactions.core.ui.context.TransitionBounds import com.bumble.appyx.interactions.core.ui.context.UiContext import com.bumble.appyx.interactions.core.ui.output.ElementUiModel +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside.Value +import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionOutside +import com.bumble.appyx.interactions.core.ui.property.motionPropertyRenderValue import com.bumble.appyx.navigation.integration.LocalScreenSize import com.bumble.appyx.navigation.node.ParentNode import kotlin.math.roundToInt @@ -58,6 +63,7 @@ fun ParentNode.Ap val screenWidthPx = (LocalScreenSize.current.widthDp * density.density).value.roundToInt() val screenHeightPx = (LocalScreenSize.current.heightDp * density.density).value.roundToInt() val coroutineScope = rememberCoroutineScope() + var containerSize by remember { mutableStateOf(IntSize.Zero) } var uiContext by remember { mutableStateOf(null) } val childrenBlock = block ?: { children { child, _ -> @@ -68,11 +74,13 @@ fun ParentNode.Ap LaunchedEffect(uiContext) { uiContext?.let { appyxComponent.updateContext(it) } } + Box( modifier = modifier .fillMaxSize() .then(if (clipToBounds) Modifier.clipToBounds() else Modifier) .onPlaced { + containerSize = it.size uiContext = UiContext( coroutineScope = coroutineScope, transitionBounds = TransitionBounds( @@ -90,18 +98,17 @@ fun ParentNode.Ap appyxComponent.onRelease() } } - .fillMaxSize() ) { - CompositionLocalProvider(LocalBoxScope provides this) { + CompositionLocalProvider(LocalBoxScope provides this@Box) { childrenBlock( - ChildrenTransitionScope(appyxComponent, gestureExtraTouchArea, gestureValidator) + ChildrenTransitionScope(containerSize, appyxComponent, gestureExtraTouchArea, gestureValidator) ) } } - } class ChildrenTransitionScope( + private val containerSize: IntSize, private val appyxComponent: BaseAppyxComponent, private val gestureExtraTouchArea: Dp, private val gestureValidator: GestureValidator @@ -132,65 +139,92 @@ class ChildrenTransitionScope( uiModels .forEach { elementUiModel -> - key(elementUiModel.element.id) { - var transformedBoundingBox by remember(elementUiModel.element.id) { - mutableStateOf(Rect.Zero) - } - var offsetCenter by remember(elementUiModel.element.id) { mutableStateOf(Offset.Zero) } - var size by remember(elementUiModel.element.id) { mutableStateOf(IntSize.Zero) } + val id = elementUiModel.element.id + + key(id) { + var transformedBoundingBox by remember(id) { mutableStateOf(Rect.Zero) } + var elementSize by remember(id) { mutableStateOf(IntSize.Zero) } + var offsetCenter by remember(id) { mutableStateOf(Offset.Zero) } val isVisible by elementUiModel.visibleState.collectAsState() + elementUiModel.persistentContainer() + if (isVisible) { - Box( - modifier = Modifier - .offset { offsetCenter.round() } - .width(with(density) { size.width.toDp() }) - .height(with(density) { size.height.toDp() }) - .pointerInput(appyxComponent) { - detectDragGesturesOrCancellation( - onDragStart = { position -> - appyxComponent.onStartDrag(position) - }, - onDrag = { change, dragAmount -> - if (gestureValidator.isGestureValid( - change.position, - transformedBoundingBox.translate(-offsetCenter) - ) - ) { - change.consume() - appyxComponent.onDrag(dragAmount, density) - true - } else { - appyxComponent.onDragEnd() - false - } - }, - onDragEnd = { - appyxComponent.onDragEnd() - }, - ) - } - ) - Child( - elementUiModel = elementUiModel.copy( - modifier = Modifier - .then(elementUiModel.modifier) - .onPlaced { - size = it.size - val localCenter = Offset( - it.size.width.toFloat(), - it.size.height.toFloat() - ) / 2f - transformedBoundingBox = - it.boundsInParent().inflate(gestureExtraTouchAreaPx) - offsetCenter = transformedBoundingBox.center - localCenter - } - ), - saveableStateHolder = saveableStateHolder, - decorator = block - ) + CompositionLocalProvider( + LocalMotionProperties provides elementUiModel.motionProperties + ) { + val elementOffset = offsetCenter.round() - elementOffset(elementSize, containerSize) + + Child( + elementUiModel = elementUiModel.copy( + modifier = Modifier + .offset { elementOffset } + .pointerInput(appyxComponent) { + detectDragGesturesOrCancellation( + onDragStart = { position -> + appyxComponent.onStartDrag(position) + }, + onDrag = { change, dragAmount -> + if (gestureValidator.isGestureValid( + change.position, + transformedBoundingBox.translate(-offsetCenter) + ) + ) { + change.consume() + appyxComponent.onDrag(dragAmount, density) + true + } else { + appyxComponent.onDragEnd() + false + } + }, + onDragEnd = { + appyxComponent.onDragEnd() + }, + ) + } + .offset { -elementOffset } + .then(elementUiModel.modifier) + .onPlaced { + elementSize = it.size + val localCenter = Offset( + it.size.width.toFloat(), + it.size.height.toFloat() + ) / 2f + transformedBoundingBox = + it + .boundsInParent() + .inflate(gestureExtraTouchAreaPx) + offsetCenter = transformedBoundingBox.center - localCenter + } + ), + saveableStateHolder = saveableStateHolder, + decorator = block + ) + } } } } } + + @Composable + private fun elementOffset( + elementSize: IntSize, + containerSize: IntSize + ): IntOffset { + + val positionInside = motionPropertyRenderValue() + val positionOutside = motionPropertyRenderValue() + val layoutDirection = LocalLayoutDirection.current + + val positionInsideOffset = positionInside?.let { + it.alignment.align(elementSize, containerSize, layoutDirection) + } ?: IntOffset.Zero + + val positionOutsideOffset = positionOutside?.let { + it.alignment.align(elementSize, containerSize, layoutDirection) + } ?: IntOffset.Zero + + return positionInsideOffset + positionOutsideOffset + } } diff --git a/demos/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/spotlight/SpotlightNode.kt b/demos/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/spotlight/SpotlightNode.kt index 190552d73..63402692b 100644 --- a/demos/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/spotlight/SpotlightNode.kt +++ b/demos/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/spotlight/SpotlightNode.kt @@ -3,6 +3,7 @@ package com.bumble.appyx.navigation.node.spotlight import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +15,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -65,16 +69,21 @@ class SpotlightNode( override fun resolve(interactionTarget: InteractionTarget, buildContext: BuildContext): Node = when (interactionTarget) { is InteractionTarget.Child -> node(buildContext) { modifier -> - val backgroundColor = remember { colors.shuffled().random() } + val backgroundColorIdx = rememberSaveable { colors.shuffled().indices.random() } + val backgroundColor = colors[backgroundColorIdx] + var clicked by rememberSaveable { mutableStateOf(false) } + Box( modifier = modifier .fillMaxSize() .clip(RoundedCornerShape(5)) .background(backgroundColor) + .clickable { clicked = true } .padding(24.dp) + ) { Text( - text = interactionTarget.index.toString(), + text = "${interactionTarget.index} – Clicked: $clicked", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Bold