Skip to content

Commit

Permalink
Merge branch '2.x' into fix-bounds-on-readd
Browse files Browse the repository at this point in the history
  Conflicts:
 	CHANGELOG.md
  • Loading branch information
zsoltk committed Sep 12, 2023
2 parents 166a270 + cf7a88b commit 82a9927
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 18 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@


### Fixed

- [#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
- [#571](https://github.com/bumble-tech/appyx/pull/571) – Avoid `MotionController` recreation
- [#587](https://github.com/bumble-tech/appyx/pull/587) – Fix `DraggableChildren` and rename it to `AppyxComponent`
- [#588](https://github.com/bumble-tech/appyx/pull/588) – Set bounds on all new motion controllers
- [#589](https://github.com/bumble-tech/appyx/pull/589) – Fix visibility resolution for elements that do not match parent's size


## 2.0.0-alpha04
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package com.bumble.appyx.components.experimental.promoter.ui

import androidx.compose.animation.core.SpringSpec
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.bumble.appyx.components.experimental.promoter.PromoterModel
Expand Down Expand Up @@ -71,7 +70,7 @@ class PromoterMotionController<InteractionTarget : Any>(
private lateinit var destroyed: TargetUiState

@Suppress("LongMethod")
fun createTargetUiStates(radius: Float) {
private fun createTargetUiStates(radius: Float) {
created = TargetUiState(
position = PositionInside.Target(alignment = Center),
angularPosition = AngularPosition.Target(
Expand Down
3 changes: 2 additions & 1 deletion appyx-interactions/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ dependencies {
api(project(":appyx-interactions:appyx-interactions"))
api(libs.compose.ui.test.junit4)
implementation(libs.androidx.test.core)

implementation(composeBom)

androidTestImplementation(composeBom)
androidTestImplementation(libs.compose.ui.test.junit4)

debugRuntimeOnly(libs.compose.ui.test.manifest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package com.bumble.appyx.interactions.ui.state

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.bumble.appyx.interactions.core.ui.LocalBoxScope
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.property.impl.position.BiasAlignment
import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.math.roundToInt

@OptIn(ExperimentalCoroutinesApi::class)
class MutableUiStateTest {

@get:Rule
val composeTestRule = createComposeRule()

private lateinit var testMutableUiState: TestMutableUiState
private lateinit var coroutineScope: CoroutineScope

private fun setupTestMutableUiState(
target: PositionInside.Target = PositionInside.Target(
alignment = BiasAlignment.InsideAlignment.TopStart
),
clipToBounds: Boolean = false,
containerModifier: Modifier = Modifier
.fillMaxSize(),
childModifier: Modifier = Modifier,
) {
composeTestRule.setContent {
BoxWithConstraints(
modifier = containerModifier
) {
CompositionLocalProvider(LocalBoxScope provides this@BoxWithConstraints) {
val density = LocalDensity.current
val localConfiguration = LocalConfiguration.current
coroutineScope = rememberCoroutineScope()
val uiContext = remember { UiContext(coroutineScope, clipToBounds) }
testMutableUiState = remember {
TestMutableUiState(
uiContext = uiContext,
position = PositionInside(
coroutineScope = coroutineScope,
target = target,
)
).apply {
updateBounds(
TransitionBounds(
density = density,
widthPx = this@BoxWithConstraints.constraints.maxWidth,
heightPx = this@BoxWithConstraints.constraints.maxHeight,
screenWidthPx = (localConfiguration.screenWidthDp * density.density).roundToInt(),
screenHeightPx = (localConfiguration.screenHeightDp * density.density).roundToInt(),
)
)
}
}
Box(
modifier = childModifier
.then(testMutableUiState.visibilityModifier)
)
}
}
}
}

@Test
fun GIVEN_visible_state_WHEN_moved_outside_of_screen_THEN_visibility_is_false() = runTest {
// child is in the top-left corner with the size of 60dp
val childSize = 60.dp
setupTestMutableUiState(
childModifier = Modifier
.requiredSize(childSize)
.background(color = Color.Red)
)

// moving the child to the top-right corner + offset its size -> pushes it off screen
testMutableUiState.snapTo(
target = TestTargetUiState(
position = PositionInside.Target(
alignment = BiasAlignment.InsideAlignment.TopEnd,
offset = DpOffset(x = childSize, y = 0.dp)
)
)
)

// assert it's invisible
composeTestRule.waitForIdle()
assertFalse(testMutableUiState.isVisible.value)
}

@Test
fun GIVEN_visible_state_WHEN_not_moved_outside_of_screen_THEN_visibility_is_true() = runTest {
val childSize = 60.dp

setupTestMutableUiState(
childModifier = Modifier
.requiredSize(childSize)
.background(color = Color.Red)
)

val offset = childSize - 1.dp
// moving the child to the top-right corner + offset less than its size -> make it just visible
testMutableUiState.snapTo(
target = TestTargetUiState(
position = PositionInside.Target(
alignment = BiasAlignment.InsideAlignment.TopEnd,
offset = DpOffset(x = offset, y = 0.dp)
)
)
)

// assert it's visible
composeTestRule.waitForIdle()
assertTrue(testMutableUiState.isVisible.value)
}

@Test
fun GIVEN_visible_state_and_clipToBounds_WHEN_moved_outside_of_parent_THEN_visibility_is_false() = runTest {
// child is in the top-left corner with the size of 60dp
val childSize = 60.dp
val parentSize = 120.dp
setupTestMutableUiState(
clipToBounds = true,
containerModifier = Modifier
.requiredSize(parentSize),
childModifier = Modifier
.requiredSize(childSize)
.background(color = Color.Red)
)

// moving the child with offset that equals parent's size -> pushes it off parent's bounds
testMutableUiState.snapTo(
target = TestTargetUiState(
position = PositionInside.Target(
offset = DpOffset(x = parentSize, y = 0.dp)
)
)
)

// assert it's invisible
composeTestRule.waitForIdle()
assertFalse(testMutableUiState.isVisible.value)
}

@Test
fun GIVEN_visible_state_and_clipToBounds_WHEN_not_moved_outside_of_parent_THEN_visibility_is_true() = runTest {
// child is in the top-left corner with the size of 60dp
val childSize = 60.dp
val parentSize = 120.dp
setupTestMutableUiState(
clipToBounds = true,
containerModifier = Modifier
.requiredSize(parentSize),
childModifier = Modifier
.requiredSize(childSize)
.background(color = Color.Red)
)

// moving the child with offset that less parent's size -> just visible is parent's bounds
val offset = parentSize - 1.dp
testMutableUiState.snapTo(
target = TestTargetUiState(
position = PositionInside.Target(
offset = DpOffset(x = offset, y = 0.dp)
)
)
)

// assert it's visible
composeTestRule.waitForIdle()
assertTrue(testMutableUiState.isVisible.value)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.bumble.appyx.interactions.ui.state

import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.ui.Modifier
import com.bumble.appyx.interactions.core.ui.context.UiContext
import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside
import com.bumble.appyx.interactions.core.ui.state.BaseMutableUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch

class TestMutableUiState(
uiContext: UiContext,
val position: PositionInside,
) : BaseMutableUiState<TestTargetUiState>(
uiContext = uiContext,
motionProperties = listOf(position),
) {
override val combinedMotionPropertyModifier: Modifier = Modifier
.then(position.modifier)

override suspend fun animateTo(
scope: CoroutineScope,
target: TestTargetUiState,
springSpec: SpringSpec<Float>,
) {
listOf(
scope.async {
position.animateTo(
target.position.value,
spring(springSpec.dampingRatio, springSpec.stiffness),
)
},
).awaitAll()
}

override suspend fun snapTo(target: TestTargetUiState) {
position.snapTo(target.position.value)
}

override fun lerpTo(
scope: CoroutineScope,
start: TestTargetUiState,
end: TestTargetUiState,
fraction: Float,
) {
scope.launch {
position.lerpTo(start.position, end.position, fraction)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.bumble.appyx.interactions.ui.state

import com.bumble.appyx.interactions.core.ui.property.impl.position.PositionInside

class TestTargetUiState(val position: PositionInside.Target)
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package com.bumble.appyx.interactions.core.ui.state

import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
Expand All @@ -14,6 +15,11 @@ import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.bumble.appyx.combineState
import com.bumble.appyx.interactions.core.ui.context.TransitionBounds
import com.bumble.appyx.interactions.core.ui.context.TransitionBoundsAware
Expand All @@ -39,13 +45,23 @@ abstract class BaseMutableUiState<TargetUiState>(
)
)

abstract val modifier: Modifier
val modifier
get() = combinedMotionPropertyModifier
.then(sizeChangedModifier)

protected abstract val combinedMotionPropertyModifier: Modifier

private val sizeChangedModifier: Modifier = Modifier
.onSizeChanged { size ->
this.size.update { size }
}

private val _isBoundsVisible = MutableStateFlow(false)
private val visibilitySources: Iterable<StateFlow<Boolean>> =
motionProperties.mapNotNull { it.isVisibleFlow } + _isBoundsVisible

@Suppress("unused")
protected val size = MutableStateFlow(IntSize.Zero)

val isVisible: StateFlow<Boolean> = combineState(
visibilitySources,
uiContext.coroutineScope
Expand All @@ -54,21 +70,32 @@ abstract class BaseMutableUiState<TargetUiState>(
}

/**
* This modifier duplicates original modifier, and will be placed on the dummy compose view
* to calculate bounds relative to parent and eventually update bounds visibility relative
* to parent's bounds. Because it's responsible only for calculating element's bounds it ensures
* that it's invisible by setting alpha as 0f. Additionally, it makes sure that it occupies all
* available space by applying fillMaxSize().
* This modifier duplicates original modifier, and will be placed on the dummy compose view with
* the same size as original composable to calculate bounds relative to parent, and eventually update
* bounds visibility relative to parent's bounds. Because it's responsible only for calculating
* element's bounds it ensures that it's invisible by setting alpha as 0f.
*/
@Suppress("unused")
val visibilityModifier: Modifier
get() = Modifier
.graphicsLayer {
// Making sure that this modifier is invisible
alpha = 0f
}
.then(modifier)
.fillMaxSize()
.then(combinedMotionPropertyModifier)
.composed {
val size by size.collectAsState()
if (size != IntSize.Zero) {
val localDensity = LocalDensity.current.density
requiredSize(
DpSize(
(size.width / localDensity).dp,
(size.height / localDensity).dp
)
)
} else {
fillMaxSize()
}
}
.onGloballyPositioned { coordinates ->
if (uiContext.clipToBounds) {
_isBoundsVisible.update {
Expand Down
Loading

0 comments on commit 82a9927

Please sign in to comment.