From ab04b13bd8f3b0bafcce5975d38b30a27923d164 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Wed, 19 Aug 2026 10:00:50 +0100 Subject: [PATCH 1/2] Update sharedBounds / sharedElement to check if the item is visible in the scrolling list before adding it to the modifier --- .../example/jetsnack/ui/components/Snacks.kt | 269 +++++++++++------- .../java/com/example/jetsnack/ui/home/Feed.kt | 10 +- Jetsnack/benchmark/build.gradle.kts | 62 ++++ .../benchmark/src/main/AndroidManifest.xml | 3 + .../jetsnack/benchmark/JetsnackBenchmarks.kt | 75 +++++ Jetsnack/build.gradle.kts | 1 + Jetsnack/gradle/libs.versions.toml | 2 + Jetsnack/settings.gradle.kts | 1 + 8 files changed, 320 insertions(+), 103 deletions(-) create mode 100644 Jetsnack/benchmark/build.gradle.kts create mode 100644 Jetsnack/benchmark/src/main/AndroidManifest.xml create mode 100644 Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt index 028ee2b1ce..426da5b9e1 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt @@ -56,10 +56,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onLayoutRectChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode @@ -188,13 +192,22 @@ private fun Snacks(snackCollectionId: Long, snacks: List, onSnackClick: ( @Composable fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier) { + var isVisible by remember { mutableStateOf(false) } + JetsnackSurface( shape = MaterialTheme.shapes.medium, - modifier = modifier.padding( - start = 4.dp, - end = 4.dp, - bottom = 8.dp, - ), + modifier = modifier + .padding( + start = 4.dp, + end = 4.dp, + bottom = 8.dp, + ) + .onLayoutRectChanged { bounds -> + val visible = bounds.fractionVisibleInWindow() > 0f + if (visible != isVisible) { + isVisible = visible + } + }, ) { val sharedTransitionScope = LocalSharedTransitionScope.current @@ -211,24 +224,50 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String }) .padding(8.dp), ) { + val imageSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Image, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + ) + } else { + Modifier + } + SnackImage( imageRes = snack.imageRes, elevation = 1.dp, contentDescription = null, modifier = Modifier .size(120.dp) - .sharedBounds( - rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Image, - ), + .then(imageSharedBoundsModifier), + ) + + val textSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Title, ), - animatedVisibilityScope = animatedVisibilityScope, - boundsTransform = snackDetailBoundsTransform, ), - ) + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + boundsTransform = snackDetailBoundsTransform, + ) + } else { + Modifier + } + Text( text = snack.name, style = MaterialTheme.typography.titleMedium, @@ -236,20 +275,7 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String modifier = Modifier .padding(top = 8.dp) .wrapContentWidth() - .sharedBounds( - rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Title, - ), - ), - animatedVisibilityScope = animatedVisibilityScope, - enter = fadeIn(nonSpatialExpressiveSpring()), - exit = fadeOut(nonSpatialExpressiveSpring()), - resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), - boundsTransform = snackDetailBoundsTransform, - ), + .then(textSharedBoundsModifier), ) } } @@ -266,6 +292,7 @@ private fun HighlightSnackItem( scrollProvider: () -> Float, modifier: Modifier = Modifier, ) { + var isVisible by remember { mutableStateOf(false) } val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No Scope found") val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current @@ -279,29 +306,42 @@ private fun HighlightSnackItem( EnterExitState.PostExit -> 20.dp } } + + val cardSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Bounds, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + clipInOverlayDuringTransition = OverlayClip( + RoundedCornerShape( + roundedCornerAnimation, + ), + ), + enter = fadeIn(), + exit = fadeOut(), + ) + } else { + Modifier + } + JetsnackCard( elevation = 0.dp, shape = RoundedCornerShape(roundedCornerAnimation), modifier = modifier .padding(bottom = 16.dp) - .sharedBounds( - sharedContentState = rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Bounds, - ), - ), - animatedVisibilityScope = animatedVisibilityScope, - boundsTransform = snackDetailBoundsTransform, - clipInOverlayDuringTransition = OverlayClip( - RoundedCornerShape( - roundedCornerAnimation, - ), - ), - enter = fadeIn(), - exit = fadeOut(), - ) + .onLayoutRectChanged { bounds -> + val visible = bounds.fractionVisibleInWindow() > 0f + if (visible != isVisible) { + isVisible = visible + } + } + .then(cardSharedBoundsModifier) .size( width = HighlightCardWidth, height = 250.dp, @@ -329,22 +369,28 @@ private fun HighlightSnackItem( .height(160.dp) .fillMaxWidth(), ) { + val bgSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Background, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + } else { + Modifier + } + Box( modifier = Modifier - .sharedBounds( - rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Background, - ), - ), - animatedVisibilityScope = animatedVisibilityScope, - boundsTransform = snackDetailBoundsTransform, - enter = fadeIn(nonSpatialExpressiveSpring()), - exit = fadeOut(nonSpatialExpressiveSpring()), - resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), - ) + .then(bgSharedBoundsModifier) .height(100.dp) .fillMaxWidth() .offsetGradientBackground( @@ -362,29 +408,55 @@ private fun HighlightSnackItem( ), ) + val imageSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Image, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + exit = fadeOut(nonSpatialExpressiveSpring()), + enter = fadeIn(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + ) + } else { + Modifier + } + SnackImage( imageRes = snack.imageRes, contentDescription = null, modifier = Modifier - .sharedBounds( - rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Image, - ), - ), - animatedVisibilityScope = animatedVisibilityScope, - exit = fadeOut(nonSpatialExpressiveSpring()), - enter = fadeIn(nonSpatialExpressiveSpring()), - boundsTransform = snackDetailBoundsTransform, - ) + .then(imageSharedBoundsModifier) .align(Alignment.BottomCenter) .size(120.dp), ) } Spacer(modifier = Modifier.height(8.dp)) + + val titleSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Title, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + } else { + Modifier + } + Text( text = snack.name, maxLines = 1, @@ -393,44 +465,37 @@ private fun HighlightSnackItem( color = JetsnackTheme.colors.textSecondary, modifier = Modifier .padding(horizontal = 16.dp) - .sharedBounds( - rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Title, - ), - ), - animatedVisibilityScope = animatedVisibilityScope, - enter = fadeIn(nonSpatialExpressiveSpring()), - exit = fadeOut(nonSpatialExpressiveSpring()), - boundsTransform = snackDetailBoundsTransform, - resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), - ) + .then(titleSharedBoundsModifier) .wrapContentWidth(), ) Spacer(modifier = Modifier.height(4.dp)) + val taglineSharedBoundsModifier = if (isVisible) { + Modifier.sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Tagline, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + } else { + Modifier + } + Text( text = snack.tagline, style = MaterialTheme.typography.bodyLarge, color = JetsnackTheme.colors.textHelp, modifier = Modifier .padding(horizontal = 16.dp) - .sharedBounds( - rememberSharedContentState( - key = SnackSharedElementKey( - snackId = snack.id, - origin = snackCollectionId.toString(), - type = SnackSharedElementType.Tagline, - ), - ), - animatedVisibilityScope = animatedVisibilityScope, - enter = fadeIn(nonSpatialExpressiveSpring()), - exit = fadeOut(nonSpatialExpressiveSpring()), - boundsTransform = snackDetailBoundsTransform, - resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), - ) + .then(taglineSharedBoundsModifier) .wrapContentWidth(), ) } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt index 10e3125ce5..9cd0fd7128 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt @@ -40,6 +40,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.jetsnack.model.Filter @@ -108,7 +111,12 @@ private fun SnackCollectionList( sharedTransitionScope: SharedTransitionScope, modifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier) { + LazyColumn( + modifier = modifier.semantics { + testTag = "feed_list" + testTagsAsResourceId = true + }, + ) { item { Spacer( Modifier.windowInsetsTopHeight( diff --git a/Jetsnack/benchmark/build.gradle.kts b/Jetsnack/benchmark/build.gradle.kts new file mode 100644 index 0000000000..a6961ffc78 --- /dev/null +++ b/Jetsnack/benchmark/build.gradle.kts @@ -0,0 +1,62 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.test) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetsnack.benchmark" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // This benchmark build type is matching the type in the target application + create("benchmark") { + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("release") + } + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/Jetsnack/benchmark/src/main/AndroidManifest.xml b/Jetsnack/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9a40236b94 --- /dev/null +++ b/Jetsnack/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt b/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt new file mode 100644 index 0000000000..e76310296a --- /dev/null +++ b/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.benchmark + +import android.content.Intent +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class JetsnackBenchmarks { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + private val targetPackageName = "com.example.jetsnack" + + @Test + fun scrollFeedFrameTiming() = benchmarkRule.measureRepeated( + packageName = targetPackageName, + metrics = listOf(FrameTimingMetric()), + compilationMode = CompilationMode.DEFAULT, + startupMode = StartupMode.COLD, + iterations = 10, + ) { + pressHome() + val context = InstrumentationRegistry.getInstrumentation().context + val intent = context.packageManager.getLaunchIntentForPackage(packageName)!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000) + + // Wait for the feed list + val listSelector = By.res("feed_list") + val list = device.wait(Until.findObject(listSelector), 15000) + ?: device.wait(Until.findObject(By.scrollable(true)), 15000) + requireNotNull(list) { "Feed list not found!" } + + list.setGestureMargin(device.displayWidth / 5) + + // Scroll down through feed + repeat(3) { + list.fling(Direction.DOWN) + device.waitForIdle() + } + + // Scroll back up + repeat(3) { + list.fling(Direction.UP) + device.waitForIdle() + } + } +} diff --git a/Jetsnack/build.gradle.kts b/Jetsnack/build.gradle.kts index e0ceb9e193..8732b128e2 100644 --- a/Jetsnack/build.gradle.kts +++ b/Jetsnack/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.version.catalog.update) alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.test) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.compose) apply false alias(libs.plugins.spotless) apply false diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml index 88caeeb200..08518c6dbe 100644 --- a/Jetsnack/gradle/libs.versions.toml +++ b/Jetsnack/gradle/libs.versions.toml @@ -4,6 +4,7 @@ android-material3 = "1.14.0" androidGradlePlugin = "9.3.1" androidx-activity-compose = "1.13.0" androidx-appcompat = "1.8.0" +androidx-benchmark = "1.3.3" androidx-compose-bom = "2026.08.00" androidx-core-splashscreen = "1.2.0" androidx-corektx = "1.19.0" @@ -110,6 +111,7 @@ androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "a androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } diff --git a/Jetsnack/settings.gradle.kts b/Jetsnack/settings.gradle.kts index 3bc8533030..62c03f96a7 100644 --- a/Jetsnack/settings.gradle.kts +++ b/Jetsnack/settings.gradle.kts @@ -38,3 +38,4 @@ dependencyResolutionManagement { } rootProject.name = "Jetsnack" include(":app") +include(":benchmark") From 8a3191cefd2b3c399eb9f4115abacfe7d4aeeb5c Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 20 Aug 2026 16:18:48 +0100 Subject: [PATCH 2/2] Add Benchmark for navigation between list -> detail --- .../example/jetsnack/ui/components/Snacks.kt | 11 +++ .../jetsnack/benchmark/JetsnackBenchmarks.kt | 73 +++++++++++++++---- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt index 426da5b9e1..08d7a0d8ad 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt @@ -68,6 +68,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density @@ -219,6 +222,10 @@ fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier + .semantics { + testTag = "snack_item" + testTagsAsResourceId = true + } .clickable(onClick = { onSnackClick(snack.id, snackCollectionId.toString()) }) @@ -355,6 +362,10 @@ private fun HighlightSnackItem( ) { Column( modifier = Modifier + .semantics { + testTag = "highlight_snack_item" + testTagsAsResourceId = true + } .clickable(onClick = { onSnackClick( snack.id, diff --git a/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt b/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt index e76310296a..0ed1056f92 100644 --- a/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt +++ b/Jetsnack/benchmark/src/main/java/com/example/jetsnack/benchmark/JetsnackBenchmarks.kt @@ -44,32 +44,79 @@ class JetsnackBenchmarks { compilationMode = CompilationMode.DEFAULT, startupMode = StartupMode.COLD, iterations = 10, + setupBlock = { + pressHome() + val context = InstrumentationRegistry.getInstrumentation().context + val intent = context.packageManager.getLaunchIntentForPackage(packageName)!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000) + device.wait(Until.hasObject(By.res("feed_list")), 15000) + }, ) { - pressHome() - val context = InstrumentationRegistry.getInstrumentation().context - val intent = context.packageManager.getLaunchIntentForPackage(packageName)!! - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - context.startActivity(intent) - device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000) - - // Wait for the feed list val listSelector = By.res("feed_list") - val list = device.wait(Until.findObject(listSelector), 15000) - ?: device.wait(Until.findObject(By.scrollable(true)), 15000) - requireNotNull(list) { "Feed list not found!" } - - list.setGestureMargin(device.displayWidth / 5) // Scroll down through feed repeat(3) { + val list = device.findObject(listSelector) ?: device.findObject(By.scrollable(true)) + requireNotNull(list) { "Feed list not found!" } + list.setGestureMargin(device.displayWidth / 5) list.fling(Direction.DOWN) device.waitForIdle() } // Scroll back up repeat(3) { + val list = device.findObject(listSelector) ?: device.findObject(By.scrollable(true)) + requireNotNull(list) { "Feed list not found!" } + list.setGestureMargin(device.displayWidth / 5) list.fling(Direction.UP) device.waitForIdle() } } + + @Test + fun navigateSnackDetailFrameTiming() = benchmarkRule.measureRepeated( + packageName = targetPackageName, + metrics = listOf(FrameTimingMetric()), + compilationMode = CompilationMode.DEFAULT, + startupMode = StartupMode.COLD, + iterations = 10, + setupBlock = { + pressHome() + val context = InstrumentationRegistry.getInstrumentation().context + val intent = context.packageManager.getLaunchIntentForPackage(packageName)!! + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000) + device.wait(Until.hasObject(By.res("feed_list")), 15000) + device.wait(Until.hasObject(By.res("highlight_snack_item")), 10000) + device.waitForIdle() + }, + ) { + val listSelector = By.res("feed_list") + + // Find snack item to click and trigger shared element transition + val snackItem = device.wait(Until.findObject(By.text("Cupcake")), 10000) + ?: device.wait(Until.findObject(By.text("Donut")), 5000) + ?: device.wait(Until.findObject(By.res("highlight_snack_item")), 5000) + ?: device.findObject(By.res("snack_item")) + ?: device.findObject(By.clickable(true)) + requireNotNull(snackItem) { "Snack item not found on feed!" } + + // Click snack to navigate into detail with shared element transition + snackItem.click() + + // Wait for detail screen to appear + val detailLoaded = device.wait(Until.hasObject(By.desc("Back")), 10000) || + device.wait(Until.hasObject(By.text("Details")), 5000) || + device.wait(Until.hasObject(By.textContains("CART")), 5000) + check(detailLoaded) { "Detail screen not loaded!" } + device.waitForIdle() + + // Return to feed with reverse shared transition + device.pressBack() + device.wait(Until.hasObject(listSelector), 10000) + device.waitForIdle() + } }