diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f884e3c9..4d8752e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### API breaking changes - [#630](https://github.com/bumble-tech/appyx/pull/630) – Pass initial state into Spotlights visualisations +- [#643](https://github.com/bumble-tech/appyx/pull/643) – Unify AppyxComponent composable between appyx-navigation and appyx-interactions modules ### Fixed diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/AppyxComponent.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/AppyxComponent.kt index cbae84b24..51c1fc66d 100644 --- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/AppyxComponent.kt +++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/AppyxComponent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -13,6 +14,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,6 +36,7 @@ import com.bumble.appyx.interactions.core.gesture.GestureValidator import com.bumble.appyx.interactions.core.gesture.GestureValidator.Companion.defaultValidator import com.bumble.appyx.interactions.core.gesture.detectDragGesturesOrCancellation 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 @@ -45,7 +48,6 @@ import com.bumble.appyx.interactions.core.ui.property.motionPropertyRenderValue private val defaultExtraTouch = 48f.dp - @Suppress("LongMethod") @Composable fun AppyxComponent( @@ -74,6 +76,18 @@ fun AppyxComponent( val gestureExtraTouchAreaPx = with(density) { gestureExtraTouchArea.toPx() } var containerSize by remember { mutableStateOf(IntSize.Zero) } + val saveableStateHolder = rememberSaveableStateHolder() + + LaunchedEffect(appyxComponent) { + appyxComponent + .removedElements() + .collect { deletedElements -> + deletedElements.forEach { + saveableStateHolder.removeState(it) + } + } + } + SideEffect { appyxComponent.updateContext( UiContext( @@ -107,7 +121,6 @@ fun AppyxComponent( CompositionLocalProvider(LocalBoxScope provides this) { 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) } @@ -120,52 +133,59 @@ fun AppyxComponent( CompositionLocalProvider( LocalMotionProperties provides elementUiModel.motionProperties ) { - val elementOffset = offsetCenter.round() - elementOffset(elementSize, containerSize) + val elementOffset = + offsetCenter.round() - elementOffset(elementSize, containerSize) - element.invoke( - 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 { + saveableStateHolder.SaveableStateProvider(key = elementUiModel.element) { + element.invoke( + 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() - 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 + }, + ) + } + .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 + } + ) ) - ) + } } } } diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt index 7a0bd0b25..bae9a5fd5 100644 --- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt +++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt @@ -1,12 +1,14 @@ package com.bumble.appyx.interactions.core.ui.output import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import com.bumble.appyx.interactions.core.Element import com.bumble.appyx.interactions.core.ui.property.MotionProperty import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +@Immutable data class ElementUiModel( val element: Element, val visibleState: StateFlow, 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 66ee5cda2..eb08e4d3a 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 @@ -1,50 +1,13 @@ package com.bumble.appyx.navigation.composable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.input.pointer.PointerEventType -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 import com.bumble.appyx.interactions.core.gesture.GestureValidator -import com.bumble.appyx.interactions.core.gesture.detectDragGesturesOrCancellation import com.bumble.appyx.interactions.core.model.BaseAppyxComponent -import com.bumble.appyx.interactions.core.model.progress.Draggable -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.PositionAlignment -import com.bumble.appyx.interactions.core.ui.property.motionPropertyRenderValue -import com.bumble.appyx.navigation.collections.ImmutableList -import com.bumble.appyx.navigation.collections.immutableListOf import com.bumble.appyx.navigation.integration.LocalScreenSize import com.bumble.appyx.navigation.node.ParentNode import kotlin.math.roundToInt @@ -56,189 +19,27 @@ internal val defaultExtraTouch = 48.dp fun ParentNode.AppyxComponent( appyxComponent: BaseAppyxComponent, modifier: Modifier = Modifier, - draggables: ImmutableList = immutableListOf(appyxComponent), clipToBounds: Boolean = false, gestureValidator: GestureValidator = GestureValidator.defaultValidator, gestureExtraTouchArea: Dp = defaultExtraTouch, - block: @Composable (ChildrenTransitionScope.() -> Unit)? = null, + decorator: (@Composable ( + child: ChildRenderer, + elementUiModel: ElementUiModel + ) -> Unit) = { child, _ -> child() } ) { val density = LocalDensity.current 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) } - val childrenBlock = block ?: { - children { child, _ -> - child() - } - } - - SideEffect { - appyxComponent.updateContext( - UiContext( - coroutineScope = coroutineScope, - clipToBounds = clipToBounds - ) - ) - } - - Box( - modifier = modifier - .fillMaxSize() - .then(if (clipToBounds) Modifier.clipToBounds() else Modifier) - .onPlaced { - containerSize = it.size - appyxComponent.updateBounds( - TransitionBounds( - density = density, - widthPx = it.size.width, - heightPx = it.size.height, - screenWidthPx = screenWidthPx, - screenHeightPx = screenHeightPx - ) - ) - } - .onPointerEvent { - if (it.type == PointerEventType.Release) { - appyxComponent.onRelease() - } - } - ) { - CompositionLocalProvider(LocalBoxScope provides this@Box) { - childrenBlock( - ChildrenTransitionScope( - containerSize = containerSize, - appyxComponent = appyxComponent, - draggables = draggables, - gestureExtraTouchArea = gestureExtraTouchArea, - gestureValidator = gestureValidator - ) - ) - } - } -} - -class ChildrenTransitionScope( - private val containerSize: IntSize, - private val appyxComponent: BaseAppyxComponent, - private val draggables: List, - private val gestureExtraTouchArea: Dp, - private val gestureValidator: GestureValidator -) { - - @Suppress("ComposableNaming", "LongMethod", "ModifierMissing") - @Composable - fun ParentNode.children( - block: @Composable (child: ChildRenderer, elementUiModel: ElementUiModel) -> Unit - ) { - - val saveableStateHolder = rememberSaveableStateHolder() - - LaunchedEffect(this@ChildrenTransitionScope.appyxComponent) { - this@ChildrenTransitionScope.appyxComponent - .removedElements() - .collect { deletedKeys -> - deletedKeys.forEach { navKey -> - saveableStateHolder.removeState(navKey) - } - } - } - - val density = LocalDensity.current - val gestureExtraTouchAreaPx = with(density) { gestureExtraTouchArea.toPx() } - val uiModels by this@ChildrenTransitionScope.appyxComponent.uiModels.collectAsState() - val appyxComponent = this@ChildrenTransitionScope.appyxComponent - - uiModels - .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) - - Child( - elementUiModel = elementUiModel.copy( - modifier = Modifier - .offset { elementOffset } - .pointerInput(appyxComponent) { - detectDragGesturesOrCancellation( - onDragStart = { position -> - draggables.forEach { - it.onStartDrag(position) - } - }, - onDrag = { change, dragAmount -> - if (gestureValidator.isGestureValid( - change.position, - transformedBoundingBox.translate(-offsetCenter) - ) - ) { - change.consume() - draggables.forEach { - it.onDrag(dragAmount, density) - } - true - } else { - draggables.forEach { - it.onDragEnd() - } - false - } - }, - onDragEnd = { - draggables.forEach { - it.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 - fun elementOffset( - elementSize: IntSize, - containerSize: IntSize, - ): IntOffset { - val positionAlignment = motionPropertyRenderValue() - val layoutDirection = LocalLayoutDirection.current - - val alignmentOffset = positionAlignment?.let { - it.align(elementSize, containerSize, layoutDirection) - } ?: IntOffset.Zero - return alignmentOffset + com.bumble.appyx.interactions.core.AppyxComponent( + appyxComponent, + screenWidthPx, + screenHeightPx, + modifier, + clipToBounds, + gestureValidator, + gestureExtraTouchArea + ) { elementUiModel -> + Child(elementUiModel, decorator) } } diff --git a/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/Child.kt b/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/Child.kt index 6ecefa78a..63ecabbee 100644 --- a/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/Child.kt +++ b/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/composable/Child.kt @@ -2,11 +2,8 @@ package com.bumble.appyx.navigation.composable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.ui.Modifier -import com.bumble.appyx.interactions.core.ui.LocalMotionProperties import com.bumble.appyx.interactions.core.ui.output.ElementUiModel import com.bumble.appyx.navigation.node.Node import com.bumble.appyx.navigation.node.ParentNode @@ -14,20 +11,17 @@ import com.bumble.appyx.navigation.node.ParentNode @Composable fun ParentNode.Child( elementUiModel: ElementUiModel, - saveableStateHolder: SaveableStateHolder, decorator: @Composable (child: ChildRenderer, elementUiModel: ElementUiModel) -> Unit ) { val navElement = elementUiModel.element val childEntry = remember(navElement.id) { childOrCreate(navElement) } - saveableStateHolder.SaveableStateProvider(key = navElement) { - decorator( - ChildRendererImpl( - node = childEntry.node, - elementUiModel = elementUiModel - ), - elementUiModel - ) - } + decorator( + ChildRendererImpl( + node = childEntry.node, + elementUiModel = elementUiModel + ), + elementUiModel + ) } private class ChildRendererImpl( @@ -39,11 +33,7 @@ private class ChildRendererImpl( @Composable override operator fun invoke(modifier: Modifier) { Box(modifier = elementUiModel.modifier) { - CompositionLocalProvider( - LocalMotionProperties provides elementUiModel.motionProperties - ) { - node.Compose(modifier = modifier) - } + node.Compose(modifier = modifier) } } @@ -51,11 +41,7 @@ private class ChildRendererImpl( @Composable override operator fun invoke() { Box(modifier = elementUiModel.modifier) { - CompositionLocalProvider( - LocalMotionProperties provides elementUiModel.motionProperties - ) { - node.Compose(modifier = Modifier) - } + node.Compose(modifier = Modifier) } } } diff --git a/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/Savers.kt b/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/Savers.kt new file mode 100644 index 000000000..97869ea70 --- /dev/null +++ b/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/Savers.kt @@ -0,0 +1,20 @@ +package com.bumble.appyx.navigation + +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.ui.graphics.Color + +val ColorSaver = run { + val redKey = "Red" + val greenKey = "Green" + val blueKey = "Blue" + mapSaver( + save = { mapOf(redKey to it.red, greenKey to it.green, blueKey to it.blue) }, + restore = { + Color( + red = it[redKey] as Float, + green = it[greenKey] as Float, + blue = it[blueKey] as Float + ) + } + ) +} diff --git a/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt b/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt index 0de036923..d360e4433 100644 --- a/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt +++ b/demos/sandbox-appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -31,6 +31,7 @@ import com.bumble.appyx.interactions.core.ui.context.UiContext import com.bumble.appyx.interactions.core.ui.gesture.GestureFactory import com.bumble.appyx.interactions.core.ui.gesture.GestureSettleConfig import com.bumble.appyx.interactions.core.ui.helper.gestureModifier +import com.bumble.appyx.navigation.ColorSaver import com.bumble.appyx.navigation.colors import com.bumble.appyx.navigation.composable.AppyxComponent import com.bumble.appyx.navigation.modality.BuildContext @@ -73,7 +74,8 @@ class BackStackNode( override fun resolve(interactionTarget: InteractionTarget, buildContext: BuildContext): Node = when (interactionTarget) { is InteractionTarget.Child -> node(buildContext) { - val backgroundColor = remember { colors.shuffled().random() } + val backgroundColor = + rememberSaveable(saver = ColorSaver) { colors.shuffled().random() } Box( modifier = Modifier