Status Update
Comments
pa...@google.com <pa...@google.com>
je...@google.com <je...@google.com> #2
Hey thank you for filing this issue. Could you provide a sample code that we can use to verify the described behavior?
Thank you!
an...@google.com <an...@google.com> #3
for example i move my finger up on screen and you can see in following logcat velocity in y axis is positive
TAG: velocity:(0.0, 388.90985) px/sec offset:780.03516
TAG: velocity:(0.0, 743.0752) px/sec offset:667.8938
TAG: velocity:(0.0, 292.08224) px/sec offset:583.4952
TAG: velocity:(0.0, 993.74994) px/sec offset:471.7256
TAG: velocity:(0.0, 638.26825) px/sec offset:404.34738
TAG: velocity:(0.0, 482.3507) px/sec offset:329.2629
TAG: velocity:(0.0, 233.61378) px/sec offset:247.59741
TAG: velocity:(0.0, 758.8499) px/sec offset:158.94847
TAG: velocity:(0.0, 679.20667) px/sec offset:87.78984
TAG: velocity:(0.0, 725.84314) px/sec offset:2.3688889
and when offset reach to zero ,
i don't decrease offset. so
layout is fixed,but now when i move my finger up the velocity in y axis is negative
TAG: velocity:(0.0, -929.37604) px/sec offset:0.0
TAG: velocity:(0.0, -2.2106879) px/sec offset:0.0
TAG: velocity:(0.0, -472.8846) px/sec offset:0.0
TAG: velocity:(0.0, -1433.1675) px/sec offset:0.0
TAG: velocity:(0.0, -1240.49) px/sec offset:0.0
const val max = 900f
@Composable
fun NestedScrollTest() {
var offset by remember {
mutableStateOf(max)
}
fun scrollBy(ds: Float): Float {
if (offset == 0f && ds < 0f) return 0.0f
if (offset == max && ds > 0f) return 0.0f
val pv = offset
offset = (ds + offset).coerceIn(0f, max)
return offset - pv
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
return if (available.y < 0) {
val consumed = scrollBy(available.y)
Offset(x = 0f, y = consumed)
} else Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (abs(available.y) > 0f &&
available.y > 0f
) {
Offset(0f, scrollBy(available.y))
} else
super.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
Log.i(TAG, "velocity:$available offset:$offset")
return super.onPreFling(available)
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
)
LazyColumn(
modifier = Modifier
.graphicsLayer {
translationY = offset
}
.nestedScroll(connection = nestedScrollConnection)
.fillMaxWidth()
.weight(1f)
) {
items(100) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(Color.Blue)
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
ra...@google.com <ra...@google.com> #4
Hi, thank you for filing this and proving a sample.
We're updating the way velocity is calculated in the velocity tracker, but the way we add points will remain the same. I agree that the value presented by the onPreFling method seems strange, but you need to take into consideration that it hasn't been consumed by the children of the given NestedScrollConnection. Ideally, you might want to have a look at the onPostFling method where we report the velocity available after the consumption by the children (in you case the LazyColumn is automatically added as part of the nested scroll system, so it will consume part of the velocity). This is because we want to let the LazyColumn consume everything it can before we take the rest of the velocity and use it to change its offset. I took the liberty to update your sample so you can see how that would be achieved. Let me know if that's your intention. Let me know what you find.
val flingSpec = rememberSplineBasedDecay<Float>()
val nestedScrollConnection = remember {
object : NestedScrollConnection {
...
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val initialVelocity = available.y
return if (abs(initialVelocity) > 1f) {
var velocityLeft = initialVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = initialVelocity,
).animateDecay(flingSpec) {
val delta = value - lastValue
val consumed = scrollBy(delta)
lastValue = value
velocityLeft = this.velocity
// It might be necessary to cancel the animation if the fling direction change, this.cancelAnimation()
}
Velocity(available.x, velocityLeft)
} else {
Velocity(available.x, initialVelocity)
}
}
}
}
je...@google.com <je...@google.com> #5
Branch: androidx-main
commit 5b42a22514d0847dbce104a9f12216afa555b2bd
Author: Levi Albuquerque <levima@google.com>
Date: Fri May 13 13:06:45 2022
Improvements on how to calculate velocity based on pointer position
In this CL I updated the way we add data from an InputEventChange into
the velocity tracker. Now we add points deltas instead of event positions.
This will guarantee that points are consistent even if the target element
is moving, which was not considering when adding the input event position.
Test: Added tests to check the behaviour in scrolling.
Fixes: 216582726
Bug: 223440806
Bug: 227709803
Relnote: "When adding InputEventChange events to Velocity Tracker we will
consider now deltas instead of positions, this will guarantee the velocity
is correctly calculated for all cases even if the target element moves"
Change-Id: I51ec384a0424829b680b4666c7d3ce49227f45de
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/VelocityTracker.kt
M compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt
an...@google.com <an...@google.com> #6
Branch: androidx-main
commit a66e75c2cb06d1d754055093e47cbdb6162d7aeb
Author: Levi Albuquerque <levima@google.com>
Date: Fri May 27 10:59:21 2022
Improvements on how to calculate velocity based on pointer position
In this CL I updated the way we add data from an InputEventChange into
the velocity tracker. Now we add points deltas instead of event positions.
This will guarantee that points are consistent even if the target element
is moving, which was not considering when adding the input event position.
Test: Added tests to check the behaviour in scrolling.
Fixes: 216582726
Bug: 223440806
Bug: 227709803
Relnote: "When adding InputEventChange events to Velocity Tracker we will
consider now deltas instead of positions, this will guarantee the velocity
is correctly calculated for all cases even if the target element moves"
Change-Id: If9ef3b9069e8f15b9b049bc3cd8b08bfd91edd49
M compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/VelocityTracker.kt
M compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt
ra...@google.com <ra...@google.com> #7
Branch: androidx-main
commit 4e1e7aa76fe04d05362fa5cc319bd490401dfa61
Author: Levi Albuquerque <levima@google.com>
Date: Thu Jun 09 13:47:56 2022
Improvements on how to calculate velocity based on pointer position
In this CL I updated the way we add data from an InputEventChange into
the velocity tracker. Now we add points deltas instead of event positions.
This will guarantee that points are consistent even if the target element
is moving, which was not considering when adding the input event position.
Test: Added tests to check the behaviour in scrolling.
Fixes: 216582726
Bug: 223440806
Bug: 227709803
Relnote: "When adding InputEventChange events to Velocity Tracker we will
consider now deltas instead of positions, this will guarantee the velocity
is correctly calculated for all cases even if the target element moves"
Change-Id: Icea9d76a43643a6b17da11f3c539d27cb8fa6f6e
M compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
M compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/util/VelocityTracker.kt
M compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/BaseLazyListTestWithOrientation.kt
M compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollModifierTest.kt
je...@google.com <je...@google.com> #8
Actually if you use onAllNodes(someMatcher())
you can use fetchSemanticsNodes()
which will return a list that can be empty. That is what we mostly recommend for doing these kind of checks. For example, in DemoTest we implemented SemanticsNodeInteractionCollection.isNotEmpty()
isNodePresent()
.
cl...@google.com <cl...@google.com> #9
Andrey is there a clear path forward here we could take or is this worth putting on the back burner?
an...@google.com <an...@google.com> #10
an...@google.com <an...@google.com> #11
Update: there are also usecases when users want to know the amount of visible children to assert it with the expected one. The current workaround could be something like:
rule.onAllNodesWithText("item", substring = true)
.filter(isPlaced())
.assertCountEquals(1)
...
fun isPlaced() = SemanticsMatcher("isPlaced") { node -> node.layoutInfo.isPlaced }
But we need to provide an official api for this use case as well
an...@google.com <an...@google.com>
rh...@gmail.com <rh...@gmail.com> #12
Can we ask what is the use case for returning these cached nodes?
They do not affect the UI thus it seems we do not need to assert on these cached nodes when testing the UI.
With each Compose update more and more optimizations are being put into these Lazy type views.
Thus it's causing our otherwise accurate UI tests to break more and more.
Our team is confused as to why we have this complicated workaround code for simple assertions.
Our workaround
We overrode every onNode
* method we used to filter out these very unexpected nodes.
Extension Methods
// region Cache Patch
/** A patched version of [ComposeTestRule.onNode] that filters out cached views from lazy views. */
fun ComposeTestRule.onNodePatched(matcher: SemanticsMatcher) =
onNode(matcher and isNotCached())
/** A patched version of [ComposeTestRule.onNodeWithText] that filters out cached views from lazy views. */
fun ComposeTestRule.onNodeWithTextPatched(text: String, substring: Boolean = false, ignoreCase: Boolean = false, useUnmergedTree: Boolean = false) =
onAllNodesWithText(text = text, substring = substring, ignoreCase = ignoreCase, useUnmergedTree = useUnmergedTree)
.filterToOneNotCached()
/** A patched version of [ComposeTestRule.onNodeWithTag] that filters out cached views from lazy views. */
fun ComposeTestRule.onNodeWithTagPatched(testTag: String, useUnmergedTree: Boolean = false) =
onAllNodesWithTag(testTag = testTag, useUnmergedTree = useUnmergedTree)
.filterToOneNotCached()
/** A patched version of [ComposeTestRule.onNodeWithContentDescription] that filters out cached views from lazy views. */
fun ComposeTestRule.onNodeWithContentDescriptionPatched(label: String) =
onAllNodesWithContentDescription(label = label)
.filterToOneNotCached()
/** A patched version of [ComposeTestRule.onAllNodesWithText] that filters out cached views from lazy views. */
fun ComposeTestRule.onAllNodesWithTextPatched(text: String) =
onAllNodesWithText(text = text)
.filterOutCached()
/** A patched version of [ComposeTestRule.onAllNodesWithContentDescription] that filters out cached views from lazy views. */
fun ComposeTestRule.onAllNodesWithContentDescriptionPatched(label: String) =
onAllNodesWithContentDescription(label = label)
.filterOutCached()
/** Filters out cached views from lazy views and expects 1 or 0 results. */
fun SemanticsNodeInteractionCollection.filterToOneNotCached() =
filterToOne(isNotCached())
/** Filters out cached views from lazy views and expects 0 or more results. */
fun SemanticsNodeInteractionCollection.filterOutCached() =
filter(isNotCached())
/**
* Matches against only nodes that are not "cached" by lazy lists. This allows us to filter out
* nodes that do not appear in the UI but are reported by Compose's testing framework. These cached
* nodes cause issues because they will cause assertIsDisplayed to fail due to 2 nodes with the same
* values are reportedly displayed, but one is displayed and the other is cached. Cached nodes also
* cause assertDoesNotExist to fail because the cached node that does not exist on the UI does exist
* according to the test framework.
*
* This matcher is used in the methods above to globally filter out these very unexpected nodes.
* We hope Compose stops reporting these cached nodes in a future update and we can remove this patch.
*
* https://issuetracker.google.com/issues/187188981
*/
fun isNotCached() = SemanticsMatcher("isNotCached") { node -> node.layoutInfo.isPlaced }
// endregion
an...@google.com <an...@google.com> #13
Sorry about that. Agree there are no good reasons to return such cached nodes in most cases. Basically it is only really needed when you test this exact caching logic (some of our lazy layout related tests do that).
I believe they should be filtered out by default with some extra api allowing to get the unfiltered list of nodes. This bug will be dedicated to this work.
ae...@google.com <ae...@google.com> #14
At this stage I think we should only be making assertIsDisplayed
/assertIsNotDisplayed
monotonically more lenient, as they're so widely used that making either of them more strict is likely to break real-world tests and prevent people from upgrading Compose.
The way they are exact inverse of each other means we cannot make one of them more lenient without making the other one more strict. So in WIP patch strict: Boolean
parameter to allow their behavior to start to diverge. I suggest the same approach could be used to make assertIsNotDisplayed
more lenient to fix this issue.
ks...@gmail.com <ks...@gmail.com> #15
Hello team, we've recently encountered this issue while upgrading from Compose 1.4.3 to Compose 1.5.0-beta03. (I couldn't test against the stable version of Compose 1.5 due to AGP and compileSdkVersion requirements, but I haven't found any relevant fixes between the 1.5.0-beta03 and the stable 1.5.0 release.)
For example, the issue manifests in the following ways:
assertDoesNotExist
fails because a cached node still exists.assertIsDisplayed
fails because the test framework detects more than one displayed node, including cached nodes.- Tests checking the node count within the tree report inaccurate results due to cached nodes.
Could you please take a look to investigate whether this constitutes a regression? While the workaround with the node placement check (e.g.,
me...@thomaskeller.biz <me...@thomaskeller.biz> #16
This is a very serious regression that hit me as well when updating to a newer Compose version. IMHO the new optimization is not even dependent on the runtime version of the library, but was somehow introduced in the new Compose Compiler version 1.5.3, which needs to be used when the project's Kotlin version is updated to 1.9.10, because when I downgrade the runtime version of compose to my previous version (BOM 2023.04.01) the error persists.
Please, fix this issue as it's hard to figure out why LazyRow / LazyColumn testing suddenly breaks for no obvious reason.
ks...@gmail.com <ks...@gmail.com> #17
It does look like it is a regression or at least a significant behavior change introduced in Compose 1.5. Although there was no response/acknowledgment to this FR, I found
ap...@google.com <ap...@google.com> #18
Branch: androidx-main
commit 78d5dc8c9b42c32b0c8518c72181c19620a71c05
Author: Andrey Kulikov <andreykulikov@google.com>
Date: Thu Oct 12 18:29:24 2023
Filter out the deactivated semantic nodes by default and introduce assertIsDeactivated()
The children of SubcomposeLayout which are retained to be reused in future are considered deactivated. We were returning them along with the active nodes in all the semantics api which was hard to reason about as from the perspective of the users such nodes are not visible. Now we will filter out them by default, plus we introduce new APIs to be able to assert that the node is deactivated.
Test: modified affected tests to use the new function instead
Bug: 187188981
Bug: 302117973
Relnote: The children of SubcomposeLayout (and layouts like LazyColumn based on it) which are retained to be reused in future are considered deactivated. New assertIsDeactivated() test API was introduced to test such nodes. The rests of the test apis will filter out deactivated nodes by default.
Change-Id: I2ef84fb2ed578238bb20c07655c475df6fb8dbd0
M compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSlotsReuseTest.kt
M compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/LazyListSlotsReuseTest.kt
M compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/LazyNestedScrollingTest.kt
M compose/ui/ui-test/api/current.txt
M compose/ui/ui-test/api/restricted_current.txt
M compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt
M compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TestOwner.kt
M compose/ui/ui/api/current.txt
M compose/ui/ui/api/restricted_current.txt
M compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutInfo.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
M paging/paging-compose/src/androidInstrumentedTest/kotlin/androidx/paging/compose/LazyPagingItemsTest.kt
M tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridSlotsReuseTest.kt
M tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListSlotsReuseTest.kt
an...@team.bumble.com <an...@team.bumble.com> #19
an...@google.com <an...@google.com> #20
Filed separate bug
And
ap...@google.com <ap...@google.com> #21
Branch: androidx-main
commit df7e5823b5e0ad1a1da88a71b44708597848769f
Author: Andrey Kulikov <andreykulikov@google.com>
Date: Wed Dec 20 14:18:39 2023
Filter out deactivated nodes from SemanticNodes.children
In aosp/2787059 we started filtering out the deactivated nodes so users can't see them in their tests. However one use case was missed:
rule.onNodeWithTag("list")
.onChildren().assertCountEquals(expectedCount)
This code will still see the deactivated nodes as we are not filtering out deactivated nodes in the list returned by SemanticNode.children.
Test: new test in SubcomposeLayoutTest
Bug: 187188981
Bug: 305905580
Fixes: 317202262
Change-Id: I4fd3d6248fc5a24bdadfdd5c60afe31386fcf7d7
M compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsNode.kt
M compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
be...@gmail.com <be...@gmail.com> #22
Thank you very much for providing this fix!
So do I understand that right, to make sure that a node does not exist or is not displayed, we can use assertIsDeactivated()
? So assertIsDeactivated()
basically means assertDoesNotExistOrIsNotDisplayed()
?
an...@google.com <an...@google.com> #23
assertIsNotDisplayed() is still needed for nodes, which are not deactivated, but not displayed because it is composed, but not placed, or clipped.
na...@google.com <na...@google.com> #24
The following release(s) address this bug.It is possible this bug has only been partially addressed:
androidx.tv:tv-foundation:1.0.0-alpha11
Description
Initially this bug was only about allowing to assert that node is not visible for any reason In my use case I don't care for what reason the node is not displayed. Either it is because the node is not composed at all, or it was composed, but wasn't placed. This behaviour in LazyColumn is not guaranteed as prefetching can decide to precompose an item in advance. But currently we have
Aside from that we should have a solid strategy on how we work with deactivated items(the ones from lazy list reuse pool):