From 1446c730f8f76a34af1a0dda622743d7b704ab34 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Sat, 10 Oct 2020 14:01:47 -0700 Subject: [PATCH 1/2] Fix formatting to match Square Android style. --- build.gradle.kts | 80 ++-- buildSrc/build.gradle.kts | 18 +- buildSrc/src/main/java/Dependencies.kt | 102 +++--- .../java/default-android-config.gradle.kts | 38 +- .../src/main/java/publish-to-maven.gradle.kts | 98 ++--- compose-backstack-viewer/build.gradle.kts | 34 +- .../backstack/viewer/BackstackViewerTest.kt | 156 ++++---- .../compose/backstack/viewer/AppModel.kt | 156 ++++---- .../compose/backstack/viewer/AppScreen.kt | 84 ++--- .../backstack/viewer/BackstackViewerApp.kt | 214 +++++------ .../compose/backstack/viewer/Spinner.kt | 72 ++-- compose-backstack/build.gradle.kts | 26 +- .../backstack/BackstackComposableTest.kt | 169 ++++----- .../zachklipp/compose/backstack/Backstack.kt | 344 +++++++++--------- .../compose/backstack/BackstackInspector.kt | 304 ++++++++-------- .../compose/backstack/BackstackTransition.kt | 68 ++-- .../backstack/ChildSavedStateRegistry.kt | 134 +++---- .../backstack/InspectionGestureDetector.kt | 66 ++-- .../backstack/PercentageLayoutOffset.kt | 32 +- .../compose/backstack/BackstackTest.kt | 265 +++++++------- .../compose/backstack/IntOffsetSubject.kt | 25 +- .../backstack/PercentageLayoutOffsetTest.kt | 72 ++-- .../compose/backstack/SavedStateHolderTest.kt | 236 ++++++------ sample/build.gradle.kts | 24 +- .../compose/backstack/sample/SampleAppTest.kt | 20 +- .../sample/ComposeBackstackActivity.kt | 26 +- .../backstack/sample/FancyTransition.kt | 30 +- 27 files changed, 1466 insertions(+), 1427 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 891773e..89fee16 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,59 +3,59 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - repositories { - mavenCentral() - gradlePluginPortal() - google() - } - - dependencies { - classpath(Dependencies.androidGradlePlugin) - classpath(Dependencies.Kotlin.dokka) - classpath(Dependencies.Kotlin.gradlePlugin) - classpath(Dependencies.ktlint) - } + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + + dependencies { + classpath(Dependencies.androidGradlePlugin) + classpath(Dependencies.Kotlin.dokka) + classpath(Dependencies.Kotlin.gradlePlugin) + classpath(Dependencies.ktlint) + } } // See https://stackoverflow.com/questions/25324880/detect-ide-environment-with-gradle val isRunningFromIde get() = project.properties["android.injected.invoked.from.ide"] == "true" subprojects { - repositories { - google() - mavenCentral() - jcenter() - } - - tasks.withType { - kotlinOptions { - jvmTarget = "1.8" - - // Allow warnings when running from IDE, makes it easier to experiment. - if (!isRunningFromIde) { - allWarningsAsErrors = true - } - - // Required while Compose is built on a compiler that is somewhere in between Kotlin - // 1.3 and 1.4. Otherwise you'll see errors like "Runtime JAR file has version 1.3 which - // is older than required for API version 1.4" - apiVersion = "1.3" - - freeCompilerArgs = listOf( - "-Xopt-in=kotlin.RequiresOptIn", - "-Xallow-jvm-ir-dependencies", - "-Xskip-prerelease-check" - ) - } + repositories { + google() + mavenCentral() + jcenter() + } + + tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + + // Allow warnings when running from IDE, makes it easier to experiment. + if (!isRunningFromIde) { + allWarningsAsErrors = true + } + + // Required while Compose is built on a compiler that is somewhere in between Kotlin + // 1.3 and 1.4. Otherwise you'll see errors like "Runtime JAR file has version 1.3 which + // is older than required for API version 1.4" + apiVersion = "1.3" + + freeCompilerArgs = listOf( + "-Xopt-in=kotlin.RequiresOptIn", + "-Xallow-jvm-ir-dependencies", + "-Xskip-prerelease-check" + ) } + } } // Dokka config for grouped docs. repositories { - jcenter() + jcenter() } plugins { - id("org.jetbrains.dokka") + id("org.jetbrains.dokka") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index e868347..2b25339 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,18 +1,18 @@ import org.jetbrains.kotlin.konan.properties.loadProperties plugins { - `kotlin-dsl` - `kotlin-dsl-precompiled-script-plugins` + `kotlin-dsl` + `kotlin-dsl-precompiled-script-plugins` } repositories { - mavenCentral() - gradlePluginPortal() - google() + mavenCentral() + gradlePluginPortal() + google() } kotlinDslPluginOptions { - experimentalWarning.set(false) + experimentalWarning.set(false) } val versions = loadProperties("$projectDir/src/main/resources/versions.properties") @@ -20,7 +20,7 @@ val agpVersion = versions["androidGradlePluginVersion"] val kotlinVersion = versions["kotlinVersion"] val dokkaVersion = versions["dokkaVersion"] dependencies { - implementation("com.android.tools.build:gradle:$agpVersion") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") - implementation("org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion") + implementation("com.android.tools.build:gradle:$agpVersion") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + implementation("org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion") } diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 89c3cab..b440f22 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -1,69 +1,69 @@ import org.jetbrains.kotlin.gradle.utils.loadPropertyFromResources object Versions { - private const val versionsFile = "versions.properties" + private const val versionsFile = "versions.properties" - const val targetSdk = 29 - val agp = loadPropertyFromResources(versionsFile, "androidGradlePluginVersion") - private val composeDev = loadPropertyFromResources(versionsFile, "composeDevVersion") - val compose = "1.0.0-$composeDev" - val kotlin = loadPropertyFromResources(versionsFile, "kotlinVersion") - val dokka = loadPropertyFromResources(versionsFile, "dokkaVersion") + const val targetSdk = 29 + val agp = loadPropertyFromResources(versionsFile, "androidGradlePluginVersion") + private val composeDev = loadPropertyFromResources(versionsFile, "composeDevVersion") + val compose = "1.0.0-$composeDev" + val kotlin = loadPropertyFromResources(versionsFile, "kotlinVersion") + val dokka = loadPropertyFromResources(versionsFile, "dokkaVersion") } object Dependencies { - val androidGradlePlugin = "com.android.tools.build:gradle:${Versions.agp}" + val androidGradlePlugin = "com.android.tools.build:gradle:${Versions.agp}" - object AndroidX { - const val activity = "androidx.activity:activity:1.1.0" - const val annotations = "androidx.annotation:annotation:1.1.0" - const val appcompat = "androidx.appcompat:appcompat:1.1.0" - const val constraintLayout = "androidx.constraintlayout:constraintlayout:1.1.3" - const val fragment = "androidx.fragment:fragment:1.2.2" + object AndroidX { + const val activity = "androidx.activity:activity:1.1.0" + const val annotations = "androidx.annotation:annotation:1.1.0" + const val appcompat = "androidx.appcompat:appcompat:1.1.0" + const val constraintLayout = "androidx.constraintlayout:constraintlayout:1.1.3" + const val fragment = "androidx.fragment:fragment:1.2.2" - // Note that we're not using the actual androidx material dep yet, it's still alpha. - const val material = "com.google.android.material:material:1.1.0" - const val recyclerview = "androidx.recyclerview:recyclerview:1.1.0" + // Note that we're not using the actual androidx material dep yet, it's still alpha. + const val material = "com.google.android.material:material:1.1.0" + const val recyclerview = "androidx.recyclerview:recyclerview:1.1.0" - // Note that we are *not* using lifecycle-viewmodel-savedstate, which at this - // writing is still in beta and still fixing bad bugs. Probably we'll never bother to, - // it doesn't really add value for us. - const val savedstate = "androidx.savedstate:savedstate:1.0.0" - const val transition = "androidx.transition:transition:1.3.1" - const val viewbinding = "androidx.databinding:viewbinding:3.6.1" + // Note that we are *not* using lifecycle-viewmodel-savedstate, which at this + // writing is still in beta and still fixing bad bugs. Probably we'll never bother to, + // it doesn't really add value for us. + const val savedstate = "androidx.savedstate:savedstate:1.0.0" + const val transition = "androidx.transition:transition:1.3.1" + const val viewbinding = "androidx.databinding:viewbinding:3.6.1" - const val junitExt = "androidx.test.ext:junit:1.1.1" - } + const val junitExt = "androidx.test.ext:junit:1.1.1" + } - object Compose { - val foundation = "androidx.compose.foundation:foundation:${Versions.compose}" - val icons = "androidx.compose.material:material-icons-extended:${Versions.compose}" - val material = "androidx.compose.material:material:${Versions.compose}" - val savedstate = "androidx.compose.runtime:runtime-saved-instance-state:${Versions.compose}" - val test = "androidx.ui:ui-test:${Versions.compose}" - val tooling = "androidx.ui:ui-tooling:${Versions.compose}" - val util = "androidx.compose.ui:ui-util:${Versions.compose}" - } + object Compose { + val foundation = "androidx.compose.foundation:foundation:${Versions.compose}" + val icons = "androidx.compose.material:material-icons-extended:${Versions.compose}" + val material = "androidx.compose.material:material:${Versions.compose}" + val savedstate = "androidx.compose.runtime:runtime-saved-instance-state:${Versions.compose}" + val test = "androidx.ui:ui-test:${Versions.compose}" + val tooling = "androidx.ui:ui-tooling:${Versions.compose}" + val util = "androidx.compose.ui:ui-util:${Versions.compose}" + } - object Kotlin { - const val binaryCompatibilityValidatorPlugin = - "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.1" - val dokka = "org.jetbrains.dokka:dokka-gradle-plugin:${Versions.dokka}" - val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" - val reflect = "org.jetbrains.kotlin:kotlin-reflect:${Versions.kotlin}" - - object Test { - const val common = "org.jetbrains.kotlin:kotlin-test-common" - const val annotations = "org.jetbrains.kotlin:kotlin-test-annotations-common" - const val jdk = "org.jetbrains.kotlin:kotlin-test-junit" - const val mockito = "com.nhaarman:mockito-kotlin-kt1.1:1.6.0" - } - } + object Kotlin { + const val binaryCompatibilityValidatorPlugin = + "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.1" + val dokka = "org.jetbrains.dokka:dokka-gradle-plugin:${Versions.dokka}" + val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" + val reflect = "org.jetbrains.kotlin:kotlin-reflect:${Versions.kotlin}" object Test { - const val junit = "junit:junit:4.13" - const val truth = "com.google.truth:truth:1.0.1" + const val common = "org.jetbrains.kotlin:kotlin-test-common" + const val annotations = "org.jetbrains.kotlin:kotlin-test-annotations-common" + const val jdk = "org.jetbrains.kotlin:kotlin-test-junit" + const val mockito = "com.nhaarman:mockito-kotlin-kt1.1:1.6.0" } + } + + object Test { + const val junit = "junit:junit:4.13" + const val truth = "com.google.truth:truth:1.0.1" + } - const val ktlint = "org.jlleitschuh.gradle:ktlint-gradle:9.2.0" + const val ktlint = "org.jlleitschuh.gradle:ktlint-gradle:9.2.0" } diff --git a/buildSrc/src/main/java/default-android-config.gradle.kts b/buildSrc/src/main/java/default-android-config.gradle.kts index 7610fc1..d913cf7 100644 --- a/buildSrc/src/main/java/default-android-config.gradle.kts +++ b/buildSrc/src/main/java/default-android-config.gradle.kts @@ -3,31 +3,31 @@ import com.android.build.gradle.BaseExtension plugins { - id("com.android.base") + id("com.android.base") } configure { - compileSdkVersion(Versions.targetSdk) - buildToolsVersion = "29.0.2" + compileSdkVersion(Versions.targetSdk) + buildToolsVersion = "29.0.2" - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } - defaultConfig { - minSdkVersion(21) - targetSdkVersion(Versions.targetSdk) - versionCode = 1 - versionName = "1.0" + defaultConfig { + minSdkVersion(21) + targetSdkVersion(Versions.targetSdk) + versionCode = 1 + versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } - buildFeatures.compose = true + buildFeatures.compose = true - composeOptions { - kotlinCompilerVersion = Versions.kotlin - kotlinCompilerExtensionVersion = Versions.compose - } + composeOptions { + kotlinCompilerVersion = Versions.kotlin + kotlinCompilerExtensionVersion = Versions.compose + } } diff --git a/buildSrc/src/main/java/publish-to-maven.gradle.kts b/buildSrc/src/main/java/publish-to-maven.gradle.kts index 9db696c..53405ef 100644 --- a/buildSrc/src/main/java/publish-to-maven.gradle.kts +++ b/buildSrc/src/main/java/publish-to-maven.gradle.kts @@ -2,9 +2,9 @@ import org.gradle.api.plugins.JavaBasePlugin.DOCUMENTATION_GROUP import org.jetbrains.kotlin.gradle.utils.loadPropertyFromResources plugins { - `maven-publish` - signing - id("org.jetbrains.dokka") + `maven-publish` + signing + id("org.jetbrains.dokka") } val releaseVersion = loadPropertyFromResources("versions.properties", "releaseVersion") @@ -17,65 +17,67 @@ version = "$releaseVersion+$composeDevVersion" val projectUrl = "https://github.com/zach-klippenstein/compose-backstack" val sonatypeUrl = if (isRelease) { - "https://oss.sonatype.org/service/local/staging/deploy/maven2" + "https://oss.sonatype.org/service/local/staging/deploy/maven2" } else { - "https://oss.sonatype.org/content/repositories/snapshots" + "https://oss.sonatype.org/content/repositories/snapshots" } val sonatypeUsername get() = (findProperty("SONATYPE_NEXUS_USERNAME") as? String).orEmpty() val sonatypePassword get() = (findProperty("SONATYPE_NEXUS_PASSWORD") as? String).orEmpty() val dokkaJar by tasks.creating(Jar::class) { - group = DOCUMENTATION_GROUP - description = "Assembles Kotlin docs with Dokka" - archiveClassifier.set("javadoc") - from(tasks["dokkaHtml"]) + group = DOCUMENTATION_GROUP + description = "Assembles Kotlin docs with Dokka" + archiveClassifier.set("javadoc") + from(tasks["dokkaHtml"]) } afterEvaluate { - publishing { - publications { - create("release") { - from(components["release"]) - artifact(dokkaJar) + publishing { + publications { + create("release") { + from(components["release"]) + artifact(dokkaJar) - pom { - name.set("Compose Backstack") - description.set("Composable for rendering transitions between backstacks.") - url.set(projectUrl) - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - developers { - developer { - id.set("zach-klippenstein") - name.set("Zach Klippenstein") - email.set("zach.klippenstein@gmail.com") - } - } - scm { - connection.set("scm:git:git://github.com/zach-klippenstein/compose-backstack.git") - developerConnection.set("scm:git:git@github.com:zach-klippenstein/compose-backstack.git") - url.set(projectUrl) - } - } + pom { + name.set("Compose Backstack") + description.set("Composable for rendering transitions between backstacks.") + url.set(projectUrl) + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") } - } - - repositories { - maven { - url = uri(sonatypeUrl) - credentials { - username = sonatypeUsername - password = sonatypePassword - } + } + developers { + developer { + id.set("zach-klippenstein") + name.set("Zach Klippenstein") + email.set("zach.klippenstein@gmail.com") } + } + scm { + connection.set("scm:git:git://github.com/zach-klippenstein/compose-backstack.git") + developerConnection.set( + "scm:git:git@github.com:zach-klippenstein/compose-backstack.git" + ) + url.set(projectUrl) + } } + } } - signing { - sign(publishing.publications["release"]) + repositories { + maven { + url = uri(sonatypeUrl) + credentials { + username = sonatypeUsername + password = sonatypePassword + } + } } + } + + signing { + sign(publishing.publications["release"]) + } } diff --git a/compose-backstack-viewer/build.gradle.kts b/compose-backstack-viewer/build.gradle.kts index c2f9b08..33e5f27 100644 --- a/compose-backstack-viewer/build.gradle.kts +++ b/compose-backstack-viewer/build.gradle.kts @@ -1,26 +1,26 @@ plugins { - id("com.android.library") - id("default-android-config") - kotlin("android") - id("org.jetbrains.dokka") - id("publish-to-maven") + id("com.android.library") + id("default-android-config") + kotlin("android") + id("org.jetbrains.dokka") + id("publish-to-maven") } dependencies { - compileOnly(Dependencies.Compose.tooling) + compileOnly(Dependencies.Compose.tooling) - api(project(":compose-backstack")) + api(project(":compose-backstack")) - implementation(Dependencies.AndroidX.appcompat) - implementation(Dependencies.Compose.icons) - implementation(Dependencies.Compose.foundation) - implementation(Dependencies.Compose.material) - implementation(Dependencies.Compose.savedstate) - implementation(Dependencies.Compose.tooling) + implementation(Dependencies.AndroidX.appcompat) + implementation(Dependencies.Compose.icons) + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Compose.material) + implementation(Dependencies.Compose.savedstate) + implementation(Dependencies.Compose.tooling) - testImplementation(Dependencies.Test.junit) - testImplementation(Dependencies.Test.truth) + testImplementation(Dependencies.Test.junit) + testImplementation(Dependencies.Test.truth) - androidTestImplementation(Dependencies.AndroidX.junitExt) - androidTestImplementation(Dependencies.Compose.test) + androidTestImplementation(Dependencies.AndroidX.junitExt) + androidTestImplementation(Dependencies.Compose.test) } diff --git a/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt b/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt index 64570c5..7e7981f 100644 --- a/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt +++ b/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt @@ -1,7 +1,15 @@ package com.zachklipp.compose.backstack.viewer import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.ui.test.* +import androidx.ui.test.assertHasClickAction +import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.assertIsNotSelected +import androidx.ui.test.assertIsSelected +import androidx.ui.test.createComposeRule +import androidx.ui.test.onNodeWithSubstring +import androidx.ui.test.onNodeWithTag +import androidx.ui.test.onNodeWithText +import androidx.ui.test.performClick import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -9,101 +17,101 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class BackstackViewerTest { - @get:Rule - val compose = createComposeRule() + @get:Rule + val compose = createComposeRule() - @Test - fun initialState() { - compose.setContent { - BackstackViewerApp() - } + @Test + fun initialState() { + compose.setContent { + BackstackViewerApp() + } + + onNodeWithText("Slide Transition").assertIsDisplayed() + onNodeWithSubstring("Slow animations").assertIsDisplayed() - onNodeWithText("Slide Transition").assertIsDisplayed() - onNodeWithSubstring("Slow animations").assertIsDisplayed() + onNodeWithText("one").assertIsSelected() + onNodeWithText("one, two").assertIsNotSelected() + onNodeWithText("one, two, three").assertIsNotSelected() - onNodeWithText("one").assertIsSelected() - onNodeWithText("one, two").assertIsNotSelected() - onNodeWithText("one, two, three").assertIsNotSelected() + onNodeWithText("Screen one").assertIsDisplayed() + onNodeWithSubstring("Counter:").assertIsDisplayed() + } - onNodeWithText("Screen one").assertIsDisplayed() - onNodeWithSubstring("Counter:").assertIsDisplayed() + @Test + fun transitionBackFromSingleScreen() { + compose.setContent { + BackstackViewerApp() } - @Test - fun transitionBackFromSingleScreen() { - compose.setContent { - BackstackViewerApp() - } + onNodeWithTag(backTestTag("one")).assertHasClickAction().performClick() + onNodeWithText("Screen one").assertIsDisplayed() + } - onNodeWithTag(backTestTag("one")).assertHasClickAction().performClick() - onNodeWithText("Screen one").assertIsDisplayed() + @Test + fun transitionToSecondPrefabBackstack() { + compose.setContent { + BackstackViewerApp() } - @Test - fun transitionToSecondPrefabBackstack() { - compose.setContent { - BackstackViewerApp() - } + onNodeWithText("Screen one").assertIsDisplayed() + onNodeWithText("Screen two").assertDoesNotExist() - onNodeWithText("Screen one").assertIsDisplayed() - onNodeWithText("Screen two").assertDoesNotExist() + onNodeWithText("one, two") + .assertIsNotSelected() + .performClick() + .assertIsSelected() - onNodeWithText("one, two") - .assertIsNotSelected() - .performClick() - .assertIsSelected() + onNodeWithText("Screen one").assertDoesNotExist() + onNodeWithText("Screen two").assertIsDisplayed() + } - onNodeWithText("Screen one").assertDoesNotExist() - onNodeWithText("Screen two").assertIsDisplayed() + @Test + fun transitionToThirdPrefabBackstack() { + compose.setContent { + BackstackViewerApp() } - @Test - fun transitionToThirdPrefabBackstack() { - compose.setContent { - BackstackViewerApp() - } + onNodeWithText("Screen one").assertIsDisplayed() + onNodeWithText("Screen two").assertDoesNotExist() + onNodeWithText("Screen three").assertDoesNotExist() - onNodeWithText("Screen one").assertIsDisplayed() - onNodeWithText("Screen two").assertDoesNotExist() - onNodeWithText("Screen three").assertDoesNotExist() + onNodeWithText("one, two, three") + .assertIsNotSelected() + .performClick() + .assertIsSelected() - onNodeWithText("one, two, three") - .assertIsNotSelected() - .performClick() - .assertIsSelected() + onNodeWithText("Screen one").assertDoesNotExist() + onNodeWithText("Screen two").assertDoesNotExist() + onNodeWithText("Screen three").assertIsDisplayed() + } - onNodeWithText("Screen one").assertDoesNotExist() - onNodeWithText("Screen two").assertDoesNotExist() - onNodeWithText("Screen three").assertIsDisplayed() + @Test + fun transitionBackFromPrefabBackstack() { + compose.setContent { + BackstackViewerApp() } - @Test - fun transitionBackFromPrefabBackstack() { - compose.setContent { - BackstackViewerApp() - } + onNodeWithText("one, two, three").performClick().assertIsSelected() + onNodeWithText("Screen three").assertIsDisplayed() - onNodeWithText("one, two, three").performClick().assertIsSelected() - onNodeWithText("Screen three").assertIsDisplayed() + onNodeWithTag(backTestTag("three")).performClick() + onNodeWithText("one, two").assertIsSelected() + onNodeWithText("Screen three").assertDoesNotExist() - onNodeWithTag(backTestTag("three")).performClick() - onNodeWithText("one, two").assertIsSelected() - onNodeWithText("Screen three").assertDoesNotExist() + onNodeWithTag(backTestTag("two")).performClick() + onNodeWithText("one").assertIsSelected() + onNodeWithText("Screen two").assertDoesNotExist() + } - onNodeWithTag(backTestTag("two")).performClick() - onNodeWithText("one").assertIsSelected() - onNodeWithText("Screen two").assertDoesNotExist() + @Test + fun addScreenWithFab() { + compose.setContent { + BackstackViewerApp() } - @Test - fun addScreenWithFab() { - compose.setContent { - BackstackViewerApp() - } - - onNodeWithTag(addTestTag("one")).assertHasClickAction().performClick() - onNodeWithText("Screen one+").assertIsDisplayed() - onNodeWithTag(backTestTag("one+")).assertHasClickAction().performClick() - onNodeWithText("Screen one+").assertDoesNotExist() - } + onNodeWithTag(addTestTag("one")).assertHasClickAction().performClick() + onNodeWithText("Screen one+").assertIsDisplayed() + onNodeWithTag(backTestTag("one+")).assertHasClickAction().performClick() + onNodeWithText("Screen one+").assertDoesNotExist() + } } diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppModel.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppModel.kt index f41526c..bb06d22 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppModel.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppModel.kt @@ -1,98 +1,102 @@ package com.zachklipp.compose.backstack.viewer -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.savedinstancestate.listSaver import androidx.compose.runtime.savedinstancestate.rememberSavedInstanceState +import androidx.compose.runtime.setValue import com.zachklipp.compose.backstack.BackstackTransition @Stable internal class AppModel private constructor( - namedTransitions: List>, - prefabBackstacks: List> + namedTransitions: List>, + prefabBackstacks: List> ) { - private var selectedTransitionIndex by mutableStateOf(0) + private var selectedTransitionIndex by mutableStateOf(0) - // Can't define this var in the constructor because the backing field isn't set by the time the - // currentBackstack initializer tries to read it when the @Model annotation is applied. - @Suppress("CanBePrimaryConstructorProperty") - var prefabBackstacks by mutableStateOf(prefabBackstacks) - var currentBackstack by mutableStateOf(prefabBackstacks.first()) + // Can't define this var in the constructor because the backing field isn't set by the time the + // currentBackstack initializer tries to read it when the @Model annotation is applied. + @Suppress("CanBePrimaryConstructorProperty") + var prefabBackstacks by mutableStateOf(prefabBackstacks) + var currentBackstack by mutableStateOf(prefabBackstacks.first()) - var namedTransitions = namedTransitions - set(value) { - field = value - selectedTransitionIndex = selectedTransitionIndex.coerceAtMost(value.size) - } - val selectedTransition get() = namedTransitions[selectedTransitionIndex] + var namedTransitions = namedTransitions + set(value) { + field = value + selectedTransitionIndex = selectedTransitionIndex.coerceAtMost(value.size) + } + val selectedTransition get() = namedTransitions[selectedTransitionIndex] - var slowAnimations: Boolean by mutableStateOf( false) - var inspectionEnabled: Boolean by mutableStateOf(false) + var slowAnimations: Boolean by mutableStateOf(false) + var inspectionEnabled: Boolean by mutableStateOf(false) - val bottomScreen get() = currentBackstack.first() + val bottomScreen get() = currentBackstack.first() - fun selectTransition(name: String) { - selectedTransitionIndex = namedTransitions.indexOfFirst { it.first == name } - } + fun selectTransition(name: String) { + selectedTransitionIndex = namedTransitions.indexOfFirst { it.first == name } + } - fun pushScreen(screen: String) { - val newBackstack = (currentBackstack + screen).distinct() - currentBackstack = newBackstack + fun pushScreen(screen: String) { + val newBackstack = (currentBackstack + screen).distinct() + currentBackstack = newBackstack + } + + fun popScreen() { + if (currentBackstack.size > 1) { + currentBackstack = currentBackstack.dropLast(1) } + } - fun popScreen() { - if (currentBackstack.size > 1) { - currentBackstack = currentBackstack.dropLast(1) - } + companion object { + /** + * Creates an instance of [AppModel] and saves it using [rememberSavedInstanceState]. + */ + @Composable + fun create( + namedTransitions: List>, + prefabBackstacks: List> + ): AppModel { + return rememberSavedInstanceState(saver = saver(namedTransitions, prefabBackstacks)) { + AppModel(namedTransitions, prefabBackstacks) + } } - companion object { - /** - * Creates an instance of [AppModel] and saves it using [rememberSavedInstanceState]. - */ - @Composable - fun create( - namedTransitions: List>, - prefabBackstacks: List> - ): AppModel { - return rememberSavedInstanceState(saver = saver(namedTransitions, prefabBackstacks)) { - AppModel(namedTransitions, prefabBackstacks) + private fun saver( + namedTransitions: List>, + prefabBackstacks: List> + ) = listSaver( + save = { model: AppModel -> + listOf( + model.selectedTransitionIndex, + model.currentBackstack, + model.slowAnimations, + model.inspectionEnabled + ) + }, + restore = { values -> + AppModel(namedTransitions, prefabBackstacks).also { model -> + val (restoredSelectedTransitionIndex, + restoredCurrentBackstack, + restoredSlowAnimations, + restoredInspectionEnabled + ) = values + (restoredSelectedTransitionIndex as? Int)?.let { + model.selectedTransitionIndex = it } - } - - private fun saver( - namedTransitions: List>, - prefabBackstacks: List> - ) = listSaver( - save = { model: AppModel -> - listOf( - model.selectedTransitionIndex, - model.currentBackstack, - model.slowAnimations, - model.inspectionEnabled - ) - }, - restore = { values -> - AppModel(namedTransitions, prefabBackstacks).also { model -> - val (restoredSelectedTransitionIndex, - restoredCurrentBackstack, - restoredSlowAnimations, - restoredInspectionEnabled - ) = values - (restoredSelectedTransitionIndex as? Int)?.let { - model.selectedTransitionIndex = it - } - @Suppress("UNCHECKED_CAST") - (restoredCurrentBackstack as? List)?.let { - model.currentBackstack = it - } - (restoredSlowAnimations as? Boolean)?.let { - model.slowAnimations = it - } - (restoredInspectionEnabled as? Boolean)?.let { - model.inspectionEnabled = it - } - } + @Suppress("UNCHECKED_CAST") + (restoredCurrentBackstack as? List)?.let { + model.currentBackstack = it } - ) - } + (restoredSlowAnimations as? Boolean)?.let { + model.slowAnimations = it + } + (restoredInspectionEnabled as? Boolean)?.let { + model.inspectionEnabled = it + } + } + } + ) + } } diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppScreen.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppScreen.kt index 1158a27..5871b00 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppScreen.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/AppScreen.kt @@ -28,58 +28,58 @@ internal fun backTestTag(screen: String) = "go back from $screen" @Preview @Composable private fun AppScreenPreview() { - AppScreen(name = "preview", showBack = false, onBack = {}, onAdd = {}) + AppScreen(name = "preview", showBack = false, onBack = {}, onAdd = {}) } @Composable internal fun AppScreen( - name: String, - showBack: Boolean, - onBack: () -> Unit, - onAdd: () -> Unit + name: String, + showBack: Boolean, + onBack: () -> Unit, + onAdd: () -> Unit ) { - Scaffold( - topBar = { - val navigationIcon = if (showBack) Icons.Default.ArrowBack else Icons.Default.Menu - TopAppBar( - navigationIcon = { - IconButton(onClick = onBack, modifier = Modifier.testTag(backTestTag(name))) { - Icon(navigationIcon) - } - }, - title = { Text("Screen $name") }) - }, - floatingActionButton = { - FloatingActionButton(onClick = onAdd, modifier = Modifier.testTag(addTestTag(name))) { - Icon(Icons.Default.Add) - } + Scaffold( + topBar = { + val navigationIcon = if (showBack) Icons.Default.ArrowBack else Icons.Default.Menu + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(backTestTag(name))) { + Icon(navigationIcon) + } + }, + title = { Text("Screen $name") }) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAdd, modifier = Modifier.testTag(addTestTag(name))) { + Icon(Icons.Default.Add) } - ) { - Text( - text = "Counter: ${Counter(200)}", - modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center) - ) - } + } + ) { + Text( + text = "Counter: ${Counter(200)}", + modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center) + ) + } } @Suppress("SameParameterValue") @Composable private fun Counter(periodMs: Long): Int = key(periodMs) { - // If the screen is temporarily removed from the composition, the counter will effectively - // be "paused": it will stop incrementing, but will resume from its last value when restored to - // the composition. - var value by savedInstanceState { 0 } - onActive { - val mainHandler = Handler() - var disposed = false - onDispose { disposed = true } - fun schedule() { - mainHandler.postDelayed({ - value++ - if (!disposed) schedule() - }, periodMs) - } - schedule() + // If the screen is temporarily removed from the composition, the counter will effectively + // be "paused": it will stop incrementing, but will resume from its last value when restored to + // the composition. + var value by savedInstanceState { 0 } + onActive { + val mainHandler = Handler() + var disposed = false + onDispose { disposed = true } + fun schedule() { + mainHandler.postDelayed({ + value++ + if (!disposed) schedule() + }, periodMs) } - return@key value + schedule() + } + return@key value } diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt index c0660ea..f324121 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt @@ -37,7 +37,7 @@ private val BUILTIN_BACKSTACK_TRANSITIONS = listOf(Slide, Crossfade) @Preview @Composable private fun BackstackViewerAppPreview() { - BackstackViewerApp() + BackstackViewerApp() } /** @@ -64,94 +64,94 @@ fun BackstackViewerApp( namedCustomTransitions: List> = emptyList(), prefabBackstacks: List>? = null ) = key(namedCustomTransitions, prefabBackstacks) { - val model = AppModel.create( - namedTransitions = namedCustomTransitions + BUILTIN_BACKSTACK_TRANSITIONS, - prefabBackstacks = (prefabBackstacks?.takeUnless { it.isEmpty() } ?: DEFAULT_BACKSTACKS) - ) - - // When we're on the first screen, let the activity handle the back press. - if (model.currentBackstack.size > 1) { - OnBackPressed { model.popScreen() } - } - - MaterialTheme(colors = darkColors()) { - Surface { - Column(modifier = Modifier.padding(16.dp).fillMaxSize()) { - AppControls(model) - Spacer(Modifier.preferredHeight(24.dp)) - AppScreens(model) - } - } + val model = AppModel.create( + namedTransitions = namedCustomTransitions + BUILTIN_BACKSTACK_TRANSITIONS, + prefabBackstacks = (prefabBackstacks?.takeUnless { it.isEmpty() } ?: DEFAULT_BACKSTACKS) + ) + + // When we're on the first screen, let the activity handle the back press. + if (model.currentBackstack.size > 1) { + OnBackPressed { model.popScreen() } + } + + MaterialTheme(colors = darkColors()) { + Surface { + Column(modifier = Modifier.padding(16.dp).fillMaxSize()) { + AppControls(model) + Spacer(Modifier.preferredHeight(24.dp)) + AppScreens(model) + } } + } } @Composable private fun AppControls(model: AppModel) { - Spinner( - items = model.namedTransitions.map { it.first }, - selectedItem = model.selectedTransition.first, - onSelected = { model.selectTransition(it) } - ) { - ListItem(text = { Text("$it Transition") }) - } - - Row { - Text("Slow animations: ", modifier = Modifier.gravity(Alignment.CenterVertically)) - Switch(model.slowAnimations, onCheckedChange = { model.slowAnimations = it }) - } - - Row { - Text("Inspect (pinch + drag): ", modifier = Modifier.gravity(Alignment.CenterVertically)) - Switch(model.inspectionEnabled, onCheckedChange = { model.inspectionEnabled = it }) - } - - Column { - model.prefabBackstacks.forEach { backstack -> - RadioButton( - text = backstack.joinToString(", "), - selected = backstack == model.currentBackstack, - onSelect = { model.currentBackstack = backstack } - ) - } + Spinner( + items = model.namedTransitions.map { it.first }, + selectedItem = model.selectedTransition.first, + onSelected = { model.selectTransition(it) } + ) { + ListItem(text = { Text("$it Transition") }) + } + + Row { + Text("Slow animations: ", modifier = Modifier.gravity(Alignment.CenterVertically)) + Switch(model.slowAnimations, onCheckedChange = { model.slowAnimations = it }) + } + + Row { + Text("Inspect (pinch + drag): ", modifier = Modifier.gravity(Alignment.CenterVertically)) + Switch(model.inspectionEnabled, onCheckedChange = { model.inspectionEnabled = it }) + } + + Column { + model.prefabBackstacks.forEach { backstack -> + RadioButton( + text = backstack.joinToString(", "), + selected = backstack == model.currentBackstack, + onSelect = { model.currentBackstack = backstack } + ) } + } } @Composable private fun AppScreens(model: AppModel) { - val animation = if (model.slowAnimations) { - remember { - TweenSpec(durationMillis = 2000) - } - } else null - - MaterialTheme(colors = lightColors()) { - InspectionGestureDetector(enabled = model.inspectionEnabled) { inspectionParams -> - Backstack( - backstack = model.currentBackstack, - transition = model.selectedTransition.second, - animationBuilder = animation, - modifier = Modifier.fillMaxSize().border(width = 3.dp, color = Color.Red), - inspectionParams = inspectionParams, - onTransitionStarting = { from, to, direction -> - println( - """ + val animation = if (model.slowAnimations) { + remember { + TweenSpec(durationMillis = 2000) + } + } else null + + MaterialTheme(colors = lightColors()) { + InspectionGestureDetector(enabled = model.inspectionEnabled) { inspectionParams -> + Backstack( + backstack = model.currentBackstack, + transition = model.selectedTransition.second, + animationBuilder = animation, + modifier = Modifier.fillMaxSize().border(width = 3.dp, color = Color.Red), + inspectionParams = inspectionParams, + onTransitionStarting = { from, to, direction -> + println( + """ Transitioning $direction: from: $from to: $to """.trimIndent() - ) - }, - onTransitionFinished = { println("Transition finished.") } - ) { screen -> - AppScreen( - name = screen, - showBack = screen != model.bottomScreen, - onAdd = { model.pushScreen("$screen+") }, - onBack = model::popScreen - ) - } - } + ) + }, + onTransitionFinished = { println("Transition finished.") } + ) { screen -> + AppScreen( + name = screen, + showBack = screen != model.bottomScreen, + onAdd = { model.pushScreen("$screen+") }, + onBack = model::popScreen + ) + } } + } } @Composable @@ -160,45 +160,45 @@ private fun RadioButton( selected: Boolean, onSelect: () -> Unit ) { - Box( - modifier = Modifier.selectable( - selected = selected, - onClick = { if (!selected) onSelect() } - ), - children = { - Box { - Row(Modifier.fillMaxWidth().padding(16.dp)) { - RadioButton(selected = selected, onClick = onSelect) - Text( - text = text, - style = MaterialTheme.typography.body1.merge(other = currentTextStyle()), - modifier = Modifier.padding(start = 16.dp) - ) - } - } - } - ) + Box( + modifier = Modifier.selectable( + selected = selected, + onClick = { if (!selected) onSelect() } + ), + children = { + Box { + Row(Modifier.fillMaxWidth().padding(16.dp)) { + RadioButton(selected = selected, onClick = onSelect) + Text( + text = text, + style = MaterialTheme.typography.body1.merge(other = currentTextStyle()), + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + ) } @Composable private fun OnBackPressed(onPressed: () -> Unit) { - val context = ContextAmbient.current - onCommit(context, onPressed) { - val activity = context.findComponentActivity() ?: return@onCommit - val callback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - onPressed() - } - } - activity.onBackPressedDispatcher.addCallback(callback) - onDispose { callback.remove() } + val context = ContextAmbient.current + onCommit(context, onPressed) { + val activity = context.findComponentActivity() ?: return@onCommit + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onPressed() + } } + activity.onBackPressedDispatcher.addCallback(callback) + onDispose { callback.remove() } + } } private tailrec fun Context.findComponentActivity(): ComponentActivity? { - return when (this) { - is ComponentActivity -> this - is ContextWrapper -> baseContext.findComponentActivity() - else -> null - } + return when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.findComponentActivity() + else -> null + } } diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt index 43f37fa..8014eb3 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt @@ -16,7 +16,11 @@ import androidx.compose.material.darkColors import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.lightColors -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +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.unit.dp @@ -26,21 +30,21 @@ import androidx.ui.tooling.preview.Preview @Preview @Composable private fun SpinnerPreviewLight() { - MaterialTheme(colors = lightColors()) { - Surface { - Spinner(listOf("foo"), selectedItem = "foo", onSelected = {}) { Text(it) } - } + MaterialTheme(colors = lightColors()) { + Surface { + Spinner(listOf("foo"), selectedItem = "foo", onSelected = {}) { Text(it) } } + } } @Preview @Composable private fun SpinnerPreviewDark() { - MaterialTheme(colors = darkColors()) { - Surface { - Spinner(listOf("foo"), selectedItem = "foo", onSelected = {}) { Text(it) } - } + MaterialTheme(colors = darkColors()) { + Surface { + Spinner(listOf("foo"), selectedItem = "foo", onSelected = {}) { Text(it) } } + } } /** @@ -53,36 +57,36 @@ internal fun Spinner( onSelected: (item: T) -> Unit, drawItem: @Composable() (T) -> Unit ) { - if (items.isEmpty()) return + if (items.isEmpty()) return - var isOpen by remember { mutableStateOf(false) } + var isOpen by remember { mutableStateOf(false) } - // Always draw the selected item. - Row(Modifier.clickable(onClick = { isOpen = !isOpen })) { - Box(modifier = Modifier.weight(1f).gravity(Alignment.CenterVertically)) { - drawItem(selectedItem) - } - Icon( - Icons.Default.ArrowDropDown, - modifier = Modifier.preferredWidth(48.dp).aspectRatio(1f) - ) + // Always draw the selected item. + Row(Modifier.clickable(onClick = { isOpen = !isOpen })) { + Box(modifier = Modifier.weight(1f).gravity(Alignment.CenterVertically)) { + drawItem(selectedItem) } + Icon( + Icons.Default.ArrowDropDown, + modifier = Modifier.preferredWidth(48.dp).aspectRatio(1f) + ) + } - if (isOpen) { - // TODO use DropdownPopup. - Dialog(onDismissRequest = { isOpen = false }) { - Surface(elevation = 1.dp) { - Column { - for (item in items) { - Box(Modifier.clickable(onClick = { - isOpen = false - if (item != selectedItem) onSelected(item) - })) { - drawItem(item) - } - } - } + if (isOpen) { + // TODO use DropdownPopup. + Dialog(onDismissRequest = { isOpen = false }) { + Surface(elevation = 1.dp) { + Column { + for (item in items) { + Box(Modifier.clickable(onClick = { + isOpen = false + if (item != selectedItem) onSelected(item) + })) { + drawItem(item) } + } } + } } + } } diff --git a/compose-backstack/build.gradle.kts b/compose-backstack/build.gradle.kts index 9daa30d..90f4780 100644 --- a/compose-backstack/build.gradle.kts +++ b/compose-backstack/build.gradle.kts @@ -1,21 +1,21 @@ plugins { - id("com.android.library") - id("default-android-config") - kotlin("android") - id("org.jetbrains.dokka") - id("publish-to-maven") + id("com.android.library") + id("default-android-config") + kotlin("android") + id("org.jetbrains.dokka") + id("publish-to-maven") } dependencies { - compileOnly(Dependencies.Compose.tooling) + compileOnly(Dependencies.Compose.tooling) - implementation(Dependencies.Compose.foundation) - implementation(Dependencies.Compose.savedstate) + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Compose.savedstate) - testImplementation(Dependencies.Test.junit) - testImplementation(Dependencies.Test.truth) + testImplementation(Dependencies.Test.junit) + testImplementation(Dependencies.Test.truth) - androidTestImplementation(Dependencies.AndroidX.junitExt) - androidTestImplementation(Dependencies.Compose.test) - androidTestImplementation(Dependencies.Test.truth) + androidTestImplementation(Dependencies.AndroidX.junitExt) + androidTestImplementation(Dependencies.Compose.test) + androidTestImplementation(Dependencies.Test.truth) } diff --git a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt index 9096aa2..774c4de 100644 --- a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt +++ b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt @@ -9,7 +9,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.AnimationClockAmbient import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.ui.test.* +import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.createComposeRule +import androidx.ui.test.onNodeWithText +import androidx.ui.test.runOnUiThread import com.zachklipp.compose.backstack.BackstackTransition.Crossfade import com.zachklipp.compose.backstack.BackstackTransition.Slide import org.junit.Rule @@ -19,104 +22,104 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class BackstackComposableTest { - @get:Rule - val compose = createComposeRule() - - private val clock = ManualAnimationClock(0) - private val animation = TweenSpec(durationMillis = 100) - - @Test - fun initialStateWithSingleScreen_slide() { - assertInitialStateWithSingleScreen(Slide) - } - - @Test - fun initialStateWithSingleScreen_crossfade() { - assertInitialStateWithSingleScreen(Crossfade) - } - - @Test - fun initialStateWithMultipleScreens_slide() { - assertInitialStateWithMultipleScreens(Slide) - } - - @Test - fun initialStateWithMultipleScreens_crossfade() { - assertInitialStateWithMultipleScreens(Crossfade) + @get:Rule + val compose = createComposeRule() + + private val clock = ManualAnimationClock(0) + private val animation = TweenSpec(durationMillis = 100) + + @Test + fun initialStateWithSingleScreen_slide() { + assertInitialStateWithSingleScreen(Slide) + } + + @Test + fun initialStateWithSingleScreen_crossfade() { + assertInitialStateWithSingleScreen(Crossfade) + } + + @Test + fun initialStateWithMultipleScreens_slide() { + assertInitialStateWithMultipleScreens(Slide) + } + + @Test + fun initialStateWithMultipleScreens_crossfade() { + assertInitialStateWithMultipleScreens(Crossfade) + } + + @Test + fun transition_slide() { + assertTransition(Slide) + } + + @Test + fun transition_crossfade() { + assertTransition(Crossfade) + } + + private fun assertInitialStateWithSingleScreen(transition: BackstackTransition) { + val originalBackstack = listOf("one") + compose.setContent { + Backstack(originalBackstack, transition = transition) { Text(it) } } - @Test - fun transition_slide() { - assertTransition(Slide) - } + onNodeWithText("one").assertIsDisplayed() + } - @Test - fun transition_crossfade() { - assertTransition(Crossfade) + private fun assertInitialStateWithMultipleScreens(transition: BackstackTransition) { + val originalBackstack = listOf("one", "two") + compose.setContent { + Backstack(originalBackstack, transition = transition) { Text(it) } } - private fun assertInitialStateWithSingleScreen(transition: BackstackTransition) { - val originalBackstack = listOf("one") - compose.setContent { - Backstack(originalBackstack, transition = transition) { Text(it) } - } - - onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + onNodeWithText("one").assertDoesNotExist() + } + + private fun assertTransition(transition: BackstackTransition) { + val originalBackstack = listOf("one") + val destinationBackstack = listOf("one", "two") + var backstack by mutableStateOf(originalBackstack) + compose.setContent { + Providers(AnimationClockAmbient provides clock) { + Backstack( + backstack, + animationBuilder = animation, + transition = transition + ) { Text(it) } + } } - private fun assertInitialStateWithMultipleScreens(transition: BackstackTransition) { - val originalBackstack = listOf("one", "two") - compose.setContent { - Backstack(originalBackstack, transition = transition) { Text(it) } - } + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertDoesNotExist() - onNodeWithText("two").assertIsDisplayed() - onNodeWithText("one").assertDoesNotExist() + runOnUiThread { + backstack = destinationBackstack } - private fun assertTransition(transition: BackstackTransition) { - val originalBackstack = listOf("one") - val destinationBackstack = listOf("one", "two") - var backstack by mutableStateOf(originalBackstack) - compose.setContent { - Providers(AnimationClockAmbient provides clock) { - Backstack( - backstack, - animationBuilder = animation, - transition = transition - ) { Text(it) } - } - } - - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertDoesNotExist() + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertDoesNotExist() - runOnUiThread { - backstack = destinationBackstack - } + setTransitionTime(25) - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertDoesNotExist() + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() - setTransitionTime(25) + setTransitionTime(75) - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertIsDisplayed() + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() - setTransitionTime(75) + setTransitionTime(100) - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertIsDisplayed() - - setTransitionTime(100) - - onNodeWithText("one").assertDoesNotExist() - onNodeWithText("two").assertIsDisplayed() - } + onNodeWithText("one").assertDoesNotExist() + onNodeWithText("two").assertIsDisplayed() + } - private fun setTransitionTime(time: Long) { - runOnUiThread { - clock.clockTimeMillis = time - } + private fun setTransitionTime(time: Long) { + runOnUiThread { + clock.clockTimeMillis = time } + } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt index 96c7803..3b753fb 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt @@ -27,8 +27,8 @@ internal val HIDDEN_MODIFIER = Modifier.drawOpacity(0f) * Identifies which direction a transition is being performed in. */ enum class TransitionDirection { - Forward, - Backward + Forward, + Backward } /** @@ -36,23 +36,23 @@ enum class TransitionDirection { * progress. */ private data class ScreenWrapper( - val key: T, - val transition: @Composable() (progress: Float, @Composable() () -> Unit) -> Unit + val key: T, + val transition: @Composable() (progress: Float, @Composable() () -> Unit) -> Unit ) internal data class ScreenProperties( - val modifier: Modifier, - val isVisible: Boolean + val modifier: Modifier, + val isVisible: Boolean ) @Composable private val DefaultBackstackAnimation: AnimationSpec - get() { - val context = ContextAmbient.current - return TweenSpec( - durationMillis = context.resources.getInteger(android.R.integer.config_shortAnimTime) - ) - } + get() { + val context = ContextAmbient.current + return TweenSpec( + durationMillis = context.resources.getInteger(android.R.integer.config_shortAnimTime) + ) + } /** * Renders the top of a stack of screens (as [T]s) and animates between screens when the top @@ -129,193 +129,193 @@ private val DefaultBackstackAnimation: AnimationSpec */ @Composable fun Backstack( - backstack: List, - modifier: Modifier = Modifier, - transition: BackstackTransition = BackstackTransition.Slide, - animationBuilder: AnimationSpec? = null, - onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? = null, - onTransitionFinished: (() -> Unit)? = null, - inspectionParams: InspectionParams? = null, - drawScreen: @Composable() (T) -> Unit + backstack: List, + modifier: Modifier = Modifier, + transition: BackstackTransition = BackstackTransition.Slide, + animationBuilder: AnimationSpec? = null, + onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? = null, + onTransitionFinished: (() -> Unit)? = null, + inspectionParams: InspectionParams? = null, + drawScreen: @Composable() (T) -> Unit ) { - require(backstack.isNotEmpty()) { "Backstack must contain at least 1 screen." } - onCommit(backstack) { - require(backstack.distinct().size == backstack.size) { - "Backstack must not contain duplicates: $backstack" - } + require(backstack.isNotEmpty()) { "Backstack must contain at least 1 screen." } + onCommit(backstack) { + require(backstack.distinct().size == backstack.size) { + "Backstack must not contain duplicates: $backstack" } + } - // When transitioning, contains a stable cache of the screens actually being displayed. Will not - // change even if backstack changes during the transition. - var activeKeys by remember { mutableStateOf(backstack) } - // The "top" screen being transitioned to. Used at the end of the transition to detect if the - // backstack changed and needs another transition immediately. - var targetTop by remember { mutableStateOf(backstack.last()) } - // Wrap all items to draw in a list, so that they will all share a constant "compositional - // position", which allows us to use @Pivotal machinery to preserve state. - var activeStackDrawers by remember { mutableStateOf(emptyList>()) } - // Defines the progress of the current transition animation in terms of visibility of the top - // screen. 1 means top screen is visible, 0 means top screen is entirely hidden. Must be 1 when - // no transition in progress. - val transitionProgress = animatedFloat(1f) - // Null means not transitioning. - var direction by remember { mutableStateOf(null) } - // Callback passed to animations to cleanup after the transition is done. - val onTransitionEnd = remember { - { reason: AnimationEndReason, _: Float -> - if (reason == TargetReached) { - direction = null - transitionProgress.snapTo(1f) - onTransitionFinished?.invoke() - } - } + // When transitioning, contains a stable cache of the screens actually being displayed. Will not + // change even if backstack changes during the transition. + var activeKeys by remember { mutableStateOf(backstack) } + // The "top" screen being transitioned to. Used at the end of the transition to detect if the + // backstack changed and needs another transition immediately. + var targetTop by remember { mutableStateOf(backstack.last()) } + // Wrap all items to draw in a list, so that they will all share a constant "compositional + // position", which allows us to use @Pivotal machinery to preserve state. + var activeStackDrawers by remember { mutableStateOf(emptyList>()) } + // Defines the progress of the current transition animation in terms of visibility of the top + // screen. 1 means top screen is visible, 0 means top screen is entirely hidden. Must be 1 when + // no transition in progress. + val transitionProgress = animatedFloat(1f) + // Null means not transitioning. + var direction by remember { mutableStateOf(null) } + // Callback passed to animations to cleanup after the transition is done. + val onTransitionEnd = remember { + { reason: AnimationEndReason, _: Float -> + if (reason == TargetReached) { + direction = null + transitionProgress.snapTo(1f) + onTransitionFinished?.invoke() + } } - val animation = animationBuilder ?: DefaultBackstackAnimation - val clock = AnimationClockAmbient.current - val inspector = remember { BackstackInspector(clock) } - inspector.params = inspectionParams + } + val animation = animationBuilder ?: DefaultBackstackAnimation + val clock = AnimationClockAmbient.current + val inspector = remember { BackstackInspector(clock) } + inspector.params = inspectionParams - if (direction == null && activeKeys != backstack) { - // Not in the middle of a transition and we got a new backstack. - // This will also run after a transition, to clean up old keys out of the temporary backstack. + if (direction == null && activeKeys != backstack) { + // Not in the middle of a transition and we got a new backstack. + // This will also run after a transition, to clean up old keys out of the temporary backstack. - if (backstack.last() == targetTop) { - // Don't need to transition, but some hidden keys changed to so we need to update the active - // list to ensure hidden screens that no longer exist are torn down. - activeKeys = backstack - } else { - // Remember the top we're transitioning to so we don't re-transition afterwards if we're - // showing the same top. - targetTop = backstack.last() + if (backstack.last() == targetTop) { + // Don't need to transition, but some hidden keys changed to so we need to update the active + // list to ensure hidden screens that no longer exist are torn down. + activeKeys = backstack + } else { + // Remember the top we're transitioning to so we don't re-transition afterwards if we're + // showing the same top. + targetTop = backstack.last() - // If the new top is in the old backstack, then it has probably already been seen, so the - // navigation is logically backwards, even if the new backstack actually contains more - // screens. - direction = if (targetTop in activeKeys) Backward else Forward + // If the new top is in the old backstack, then it has probably already been seen, so the + // navigation is logically backwards, even if the new backstack actually contains more + // screens. + direction = if (targetTop in activeKeys) Backward else Forward - // Mutate the stack for the transition so the keys that need to be temporarily shown are in - // the right place. - val oldTop = activeKeys.last() - val newKeys = backstack.toMutableList() - if (direction == Backward) { - // We need to put the current screen on the top of the new active stack so it will animate - // out. - newKeys += oldTop + // Mutate the stack for the transition so the keys that need to be temporarily shown are in + // the right place. + val oldTop = activeKeys.last() + val newKeys = backstack.toMutableList() + if (direction == Backward) { + // We need to put the current screen on the top of the new active stack so it will animate + // out. + newKeys += oldTop - // When going back the top screen needs to start off as visible. - transitionProgress.snapTo(1f) - transitionProgress.animateTo(0f, anim = animation, onEnd = onTransitionEnd) - } else { - // If the current screen is not the new second-last screen, we need to move it to that - // position so it animates out when going forward. This is true whether or not the current - // screen is actually in the new backstack at all. - newKeys -= targetTop - newKeys -= oldTop - newKeys += oldTop - newKeys += targetTop + // When going back the top screen needs to start off as visible. + transitionProgress.snapTo(1f) + transitionProgress.animateTo(0f, anim = animation, onEnd = onTransitionEnd) + } else { + // If the current screen is not the new second-last screen, we need to move it to that + // position so it animates out when going forward. This is true whether or not the current + // screen is actually in the new backstack at all. + newKeys -= targetTop + newKeys -= oldTop + newKeys += oldTop + newKeys += targetTop - // When going forward, the top screen needs to start off as invisible. - transitionProgress.snapTo(0f) - transitionProgress.animateTo(1f, anim = animation, onEnd = onTransitionEnd) - } - onTransitionStarting?.invoke(activeKeys, backstack, direction!!) - activeKeys = newKeys - } + // When going forward, the top screen needs to start off as invisible. + transitionProgress.snapTo(0f) + transitionProgress.animateTo(1f, anim = animation, onEnd = onTransitionEnd) + } + onTransitionStarting?.invoke(activeKeys, backstack, direction!!) + activeKeys = newKeys } + } - // Only refresh the wrappers when the keys or opacity actually change. - // We need to regenerate these if the keys in the backstack change even if the top doesn't change - // because we need to dispose of old screens that are no longer rendered. - // - // Note: This block must not contain any control flow logic that causes the screen composables - // to be invoked from different source locations. If it does, those screens will lose all their - // state as soon as a different branch is taken. See @Pivotal for more information. - activeStackDrawers = remember(activeKeys, transition) { - activeKeys.mapIndexed { index, key -> - // This wrapper composable will remain in the composition as long as its key is - // in the backstack. So we can use remember here to hold state that should persist - // even when the screen is hidden. - ScreenWrapper(key) { progress, children -> - // Inspector and transition are mutually exclusive. - val screenProperties = if (inspector.isInspectionActive) { - calculateInspectionModifier(inspector, index, activeKeys.size, progress) - } else { - calculateRegularModifier(transition, index, activeKeys.size, progress) - } + // Only refresh the wrappers when the keys or opacity actually change. + // We need to regenerate these if the keys in the backstack change even if the top doesn't change + // because we need to dispose of old screens that are no longer rendered. + // + // Note: This block must not contain any control flow logic that causes the screen composables + // to be invoked from different source locations. If it does, those screens will lose all their + // state as soon as a different branch is taken. See @Pivotal for more information. + activeStackDrawers = remember(activeKeys, transition) { + activeKeys.mapIndexed { index, key -> + // This wrapper composable will remain in the composition as long as its key is + // in the backstack. So we can use remember here to hold state that should persist + // even when the screen is hidden. + ScreenWrapper(key) { progress, children -> + // Inspector and transition are mutually exclusive. + val screenProperties = if (inspector.isInspectionActive) { + calculateInspectionModifier(inspector, index, activeKeys.size, progress) + } else { + calculateRegularModifier(transition, index, activeKeys.size, progress) + } - // This must be called even if the screen is not visible, so the screen's state gets - // cached before it's removed from the composition. - val savedStateRegistry = ChildSavedStateRegistry(screenProperties.isVisible) + // This must be called even if the screen is not visible, so the screen's state gets + // cached before it's removed from the composition. + val savedStateRegistry = ChildSavedStateRegistry(screenProperties.isVisible) - if (!screenProperties.isVisible) { - // Remove the screen from the composition. - // This must be done after updating the savedState visibility so it has a chance - // to query providers before they're unregistered. - return@ScreenWrapper - } + if (!screenProperties.isVisible) { + // Remove the screen from the composition. + // This must be done after updating the savedState visibility so it has a chance + // to query providers before they're unregistered. + return@ScreenWrapper + } - Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) { - Box(screenProperties.modifier, children = children) - } - } + Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) { + Box(screenProperties.modifier, children = children) } + } } + } - // Actually draw the screens. - Stack(modifier = modifier.clip(RectangleShape)) { - activeStackDrawers.forEach { (item, transition) -> - // Key is a convenience helper that treats its arguments as @Pivotal. This is how state - // preservation is implemented. Even if screens are moved around within the list, as long - // as they're invoked through the exact same sequence of source locations from within this - // key lambda, they will keep their state. - key(item) { - transition(transitionProgress.value) { drawScreen(item) } - } - } + // Actually draw the screens. + Stack(modifier = modifier.clip(RectangleShape)) { + activeStackDrawers.forEach { (item, transition) -> + // Key is a convenience helper that treats its arguments as @Pivotal. This is how state + // preservation is implemented. Even if screens are moved around within the list, as long + // as they're invoked through the exact same sequence of source locations from within this + // key lambda, they will keep their state. + key(item) { + transition(transitionProgress.value) { drawScreen(item) } + } } + } } internal fun calculateRegularModifier( - transition: BackstackTransition, - index: Int, - count: Int, - progress: Float + transition: BackstackTransition, + index: Int, + count: Int, + progress: Float ): ScreenProperties { - val visibility = when (index) { - // transitionProgress always corresponds directly to visibility of the top screen. - count - 1 -> progress - // The second-to-top screen has the inverse visibility of the top screen. - count - 2 -> 1f - progress - // All other screens should not be drawn at all. They're only kept around to maintain - // their composable state. - else -> 0f - } + val visibility = when (index) { + // transitionProgress always corresponds directly to visibility of the top screen. + count - 1 -> progress + // The second-to-top screen has the inverse visibility of the top screen. + count - 2 -> 1f - progress + // All other screens should not be drawn at all. They're only kept around to maintain + // their composable state. + else -> 0f + } - val screenModifier = when (visibility) { - 0f -> HIDDEN_MODIFIER - 1f -> Modifier - else -> transition.modifierForScreen(visibility, index == count - 1) - } - return ScreenProperties( - modifier = screenModifier, - isVisible = visibility != 0f - ) + val screenModifier = when (visibility) { + 0f -> HIDDEN_MODIFIER + 1f -> Modifier + else -> transition.modifierForScreen(visibility, index == count - 1) + } + return ScreenProperties( + modifier = screenModifier, + isVisible = visibility != 0f + ) } @Composable private fun calculateInspectionModifier( - inspector: BackstackInspector, - index: Int, - count: Int, - progress: Float + inspector: BackstackInspector, + index: Int, + count: Int, + progress: Float ): ScreenProperties { - val visibility = when (index) { - count - 1 -> progress - // All previous screens are always visible in inspection mode. - else -> 1f - } - return ScreenProperties( - modifier = inspector.inspectScreen(index, count, visibility), - isVisible = true - ) + val visibility = when (index) { + count - 1 -> progress + // All previous screens are always visible in inspection mode. + else -> 1f + } + return ScreenProperties( + modifier = inspector.inspectScreen(index, count, visibility), + isVisible = true + ) } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt index 8d017c6..629bd8a 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackInspector.kt @@ -7,7 +7,11 @@ import androidx.compose.animation.animate import androidx.compose.animation.core.AnimationClockObservable import androidx.compose.animation.core.FloatSpringSpec import androidx.compose.animation.core.Spring -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.drawLayer import androidx.compose.ui.platform.DensityAmbient @@ -36,13 +40,13 @@ import kotlin.math.sin */ @Immutable data class InspectionParams( - val offsetX: Dp = 500.dp, - val offsetY: Dp = 10.dp, - val rotationXDegrees: Float = 0f, - val rotationYDegrees: Float = 10f, - val scale: Float = .5f, - val opacity: Float = .4f, - val overlayOpacity: Float = .2f + val offsetX: Dp = 500.dp, + val offsetY: Dp = 10.dp, + val rotationXDegrees: Float = 0f, + val rotationYDegrees: Float = 10f, + val scale: Float = .5f, + val opacity: Float = .4f, + val overlayOpacity: Float = .2f ) /** Constrain params to reasonable limits. */ @@ -58,149 +62,149 @@ fun InspectionParams.constrained() = InspectionParams( internal class BackstackInspector(clock: AnimationClockObservable) { - private val animation = FloatSpringSpec(stiffness = Spring.StiffnessLow) - - /** - * True when the inspector is in control of rendering. - * Will continue to return true after setting [params] to null until it's finished animating. - */ - var isInspectionActive: Boolean by mutableStateOf(false) - private set - - /** - * Update the parameters used to display the rendering. - * - * Whenever new parameters are passed in, the display will animate towards them, and - * [isInspectionActive] will immediately start returning true. - * - * When null is passed, the display will animate screens back to the default state, and - * inspecting will start returning false _only after_ the default state is reached. - */ - var params: InspectionParams? = null - set(value) { - val constrainedParams = value?.constrained() - if ((field == null) != (constrainedParams == null)) { - if (constrainedParams != null) { - startInspecting() - } else { - stopInspecting() - } - } - constrainedParams?.let { - offsetDpX.animateTo(it.offsetX.value, animation) - offsetDpY.animateTo(it.offsetY.value, animation) - rotationX.animateTo(it.rotationXDegrees, animation) - rotationY.animateTo(it.rotationYDegrees, animation) - scaleFactor.animateTo(it.scale, animation) - alpha.animateTo(it.opacity, animation) - overlayAlpha.animateTo(it.overlayOpacity) - } - field = constrainedParams + private val animation = FloatSpringSpec(stiffness = Spring.StiffnessLow) + + /** + * True when the inspector is in control of rendering. + * Will continue to return true after setting [params] to null until it's finished animating. + */ + var isInspectionActive: Boolean by mutableStateOf(false) + private set + + /** + * Update the parameters used to display the rendering. + * + * Whenever new parameters are passed in, the display will animate towards them, and + * [isInspectionActive] will immediately start returning true. + * + * When null is passed, the display will animate screens back to the default state, and + * inspecting will start returning false _only after_ the default state is reached. + */ + var params: InspectionParams? = null + set(value) { + val constrainedParams = value?.constrained() + if ((field == null) != (constrainedParams == null)) { + if (constrainedParams != null) { + startInspecting() + } else { + stopInspecting() } - - private val offsetDpX = AnimatedFloatModel(INITIAL_OFFSET_X, clock) - private val offsetDpY = AnimatedFloatModel(INITIAL_OFFSET_Y, clock) - private val rotationX = AnimatedFloatModel(INITIAL_ROTATION_X, clock) - private val rotationY = AnimatedFloatModel(INITIAL_ROTATION_Y, clock) - private val scaleFactor = AnimatedFloatModel(INITIAL_SCALE, clock) - private val alpha = AnimatedFloatModel(INITIAL_ALPHA, clock) - private val overlayAlpha = AnimatedFloatModel(INITIAL_OVERLAY_ALPHA, clock) - - /** - * Calculates a [Modifier] to apply to a screen when in inspection mode. - * - * The top screen will be drawn without the usual translations, and only use - * [InspectionParams.overlayOpacity]. All other screens will be drawn as a 3D stack. - * All transformations are animated. - */ - @Composable - internal fun inspectScreen( - screenIndex: Int, - screenCount: Int, - visibility: Float - ): Modifier { - // Draw the top screen as an overlay so it's clear where touch targets are. Once - // compose supports transforming inputs as well as outputs, the top screen can - // participate in scaling/rotation too. - val isTop = screenIndex == screenCount - 1 - val density = DensityAmbient.current - - // drawLayer will scale around the center of the bounds, so we need to offset relative - // to that so the entire stack stays centered. - val centerOffset = animate( - // Don't need to adjust the pivot point if there's only one screen. - if (screenCount == 1) 0f - // Add -1 + visibility so new screens animate "out of" the previous one. - else (screenIndex - 1f + visibility) - screenCount / 3f - ) - - val scale = animate(if (isTop) 1f else scaleFactor.value) - - val offsetDpX = animate( - if (isTop) 0f else { - // Adjust by screenCount to squeeze more in as the count increases. - val densityFactor = 10f / screenCount - // Adjust X offset by sin(rotation) so it looks 3D. - val xRotation = sin(toRadians(rotationY.value.toDouble())).toFloat() - (centerOffset * offsetDpX.value * scale * densityFactor * xRotation) - } - ) - val offsetDpY = animate(if (isTop) 0f else (centerOffset * offsetDpY.value * scale)) - - val rotationX = animate(if (isTop) 0f else (rotationX.value)) - val rotationY = animate(if (isTop) 0f else (rotationY.value)) - - // This is the only transformation applied to the top screen, so it has some extra logic. - val alpha = animate( - when { - // If there's only one screen in the stack, don't transform it at all. - screenCount == 1 -> 1f - isTop -> overlayAlpha.value - else -> alpha.value - // Adjust alpha by visibility to make transition less jarring when adding/removing - // screens. - } * visibility - ) - - return Modifier.drawLayer( - scaleX = scale, - scaleY = scale, - rotationX = rotationX, - rotationY = rotationY, - translationX = with(density) { offsetDpX.dp.toPx() }, - translationY = with(density) { offsetDpY.dp.toPx() }, - alpha = alpha - ) - } - - /** Transition to inspection mode. */ - private fun startInspecting() { - isInspectionActive = true + } + constrainedParams?.let { + offsetDpX.animateTo(it.offsetX.value, animation) + offsetDpY.animateTo(it.offsetY.value, animation) + rotationX.animateTo(it.rotationXDegrees, animation) + rotationY.animateTo(it.rotationYDegrees, animation) + scaleFactor.animateTo(it.scale, animation) + alpha.animateTo(it.opacity, animation) + overlayAlpha.animateTo(it.overlayOpacity) + } + field = constrainedParams } - /** Transition away from inspection mode. */ - private fun stopInspecting() { - offsetDpX.animateTo(INITIAL_OFFSET_X, animation) - offsetDpY.animateTo(INITIAL_OFFSET_Y, animation) - rotationX.animateTo(INITIAL_ROTATION_X, animation) - rotationY.animateTo(INITIAL_ROTATION_Y, animation) - scaleFactor.animateTo(INITIAL_SCALE, animation) - alpha.animateTo(INITIAL_ALPHA, animation) - overlayAlpha.animateTo(INITIAL_OVERLAY_ALPHA, animation, onEnd = { _, _ -> - // Doesn't matter which one, but we need to listen to the end of one of the animations - // so we can tell the Backstack that we're done being in control. - isInspectionActive = false - }) - } - - private companion object { - // Values to use when the inspector is not active (inspecting is false). - const val INITIAL_OFFSET_X = 0f - const val INITIAL_OFFSET_Y = 0f - const val INITIAL_ROTATION_X = 0f - const val INITIAL_ROTATION_Y = 0f - const val INITIAL_SCALE = 1f - const val INITIAL_ALPHA = 1f - const val INITIAL_OVERLAY_ALPHA = 1f - } + private val offsetDpX = AnimatedFloatModel(INITIAL_OFFSET_X, clock) + private val offsetDpY = AnimatedFloatModel(INITIAL_OFFSET_Y, clock) + private val rotationX = AnimatedFloatModel(INITIAL_ROTATION_X, clock) + private val rotationY = AnimatedFloatModel(INITIAL_ROTATION_Y, clock) + private val scaleFactor = AnimatedFloatModel(INITIAL_SCALE, clock) + private val alpha = AnimatedFloatModel(INITIAL_ALPHA, clock) + private val overlayAlpha = AnimatedFloatModel(INITIAL_OVERLAY_ALPHA, clock) + + /** + * Calculates a [Modifier] to apply to a screen when in inspection mode. + * + * The top screen will be drawn without the usual translations, and only use + * [InspectionParams.overlayOpacity]. All other screens will be drawn as a 3D stack. + * All transformations are animated. + */ + @Composable + internal fun inspectScreen( + screenIndex: Int, + screenCount: Int, + visibility: Float + ): Modifier { + // Draw the top screen as an overlay so it's clear where touch targets are. Once + // compose supports transforming inputs as well as outputs, the top screen can + // participate in scaling/rotation too. + val isTop = screenIndex == screenCount - 1 + val density = DensityAmbient.current + + // drawLayer will scale around the center of the bounds, so we need to offset relative + // to that so the entire stack stays centered. + val centerOffset = animate( + // Don't need to adjust the pivot point if there's only one screen. + if (screenCount == 1) 0f + // Add -1 + visibility so new screens animate "out of" the previous one. + else (screenIndex - 1f + visibility) - screenCount / 3f + ) + + val scale = animate(if (isTop) 1f else scaleFactor.value) + + val offsetDpX = animate( + if (isTop) 0f else { + // Adjust by screenCount to squeeze more in as the count increases. + val densityFactor = 10f / screenCount + // Adjust X offset by sin(rotation) so it looks 3D. + val xRotation = sin(toRadians(rotationY.value.toDouble())).toFloat() + (centerOffset * offsetDpX.value * scale * densityFactor * xRotation) + } + ) + val offsetDpY = animate(if (isTop) 0f else (centerOffset * offsetDpY.value * scale)) + + val rotationX = animate(if (isTop) 0f else (rotationX.value)) + val rotationY = animate(if (isTop) 0f else (rotationY.value)) + + // This is the only transformation applied to the top screen, so it has some extra logic. + val alpha = animate( + when { + // If there's only one screen in the stack, don't transform it at all. + screenCount == 1 -> 1f + isTop -> overlayAlpha.value + else -> alpha.value + // Adjust alpha by visibility to make transition less jarring when adding/removing + // screens. + } * visibility + ) + + return Modifier.drawLayer( + scaleX = scale, + scaleY = scale, + rotationX = rotationX, + rotationY = rotationY, + translationX = with(density) { offsetDpX.dp.toPx() }, + translationY = with(density) { offsetDpY.dp.toPx() }, + alpha = alpha + ) + } + + /** Transition to inspection mode. */ + private fun startInspecting() { + isInspectionActive = true + } + + /** Transition away from inspection mode. */ + private fun stopInspecting() { + offsetDpX.animateTo(INITIAL_OFFSET_X, animation) + offsetDpY.animateTo(INITIAL_OFFSET_Y, animation) + rotationX.animateTo(INITIAL_ROTATION_X, animation) + rotationY.animateTo(INITIAL_ROTATION_Y, animation) + scaleFactor.animateTo(INITIAL_SCALE, animation) + alpha.animateTo(INITIAL_ALPHA, animation) + overlayAlpha.animateTo(INITIAL_OVERLAY_ALPHA, animation, onEnd = { _, _ -> + // Doesn't matter which one, but we need to listen to the end of one of the animations + // so we can tell the Backstack that we're done being in control. + isInspectionActive = false + }) + } + + private companion object { + // Values to use when the inspector is not active (inspecting is false). + const val INITIAL_OFFSET_X = 0f + const val INITIAL_OFFSET_Y = 0f + const val INITIAL_ROTATION_X = 0f + const val INITIAL_ROTATION_Y = 0f + const val INITIAL_SCALE = 1f + const val INITIAL_ALPHA = 1f + const val INITIAL_OVERLAY_ALPHA = 1f + } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt index 637e81b..558094a 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt @@ -16,40 +16,40 @@ import com.zachklipp.compose.backstack.BackstackTransition.Slide */ interface BackstackTransition { - /** - * Returns a [Modifier] to use to draw screen in a [Backstack]. - * - * @param visibility A float in the range `[0, 1]` that indicates at what visibility this screen - * should be drawn. For example, this value will increase when [isTop] is true and the transition - * is in the forward direction. - * @param isTop True only when being called for the top screen. E.g. if the screen is partially - * visible, then the top screen is always transitioning _out_, and non-top screens are either - * transitioning out or invisible. - */ - fun modifierForScreen( - visibility: Float, - isTop: Boolean - ): Modifier + /** + * Returns a [Modifier] to use to draw screen in a [Backstack]. + * + * @param visibility A float in the range `[0, 1]` that indicates at what visibility this screen + * should be drawn. For example, this value will increase when [isTop] is true and the transition + * is in the forward direction. + * @param isTop True only when being called for the top screen. E.g. if the screen is partially + * visible, then the top screen is always transitioning _out_, and non-top screens are either + * transitioning out or invisible. + */ + fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier - /** - * A simple transition that slides screens horizontally. - */ - object Slide : BackstackTransition { - override fun modifierForScreen( - visibility: Float, - isTop: Boolean - ): Modifier = PercentageLayoutOffset( - offset = if (isTop) 1f - visibility else -1 + visibility - ) - } + /** + * A simple transition that slides screens horizontally. + */ + object Slide : BackstackTransition { + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier = PercentageLayoutOffset( + offset = if (isTop) 1f - visibility else -1 + visibility + ) + } - /** - * A simple transition that crossfades between screens. - */ - object Crossfade : BackstackTransition { - override fun modifierForScreen( - visibility: Float, - isTop: Boolean - ): Modifier = Modifier.drawOpacity(visibility) - } + /** + * A simple transition that crossfades between screens. + */ + object Crossfade : BackstackTransition { + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier = Modifier.drawOpacity(visibility) + } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt index 593627e..df85fa9 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/ChildSavedStateRegistry.kt @@ -1,6 +1,10 @@ package com.zachklipp.compose.backstack -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLifecycleObserver +import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.remember import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistry import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistryAmbient @@ -12,81 +16,81 @@ import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistryAmbient @OptIn(ExperimentalComposeApi::class) @Composable fun ChildSavedStateRegistry(childWillBeComposed: Boolean): UiSavedStateRegistry { - val parent = UiSavedStateRegistryAmbient.current - val key = currentComposer.currentCompoundKeyHash.toString() - val holder = remember { SavedStateHolder(key) } - return holder.updateAndReturnRegistry(parent, childWillBeComposed) + val parent = UiSavedStateRegistryAmbient.current + val key = currentComposer.currentCompoundKeyHash.toString() + val holder = remember { SavedStateHolder(key) } + return holder.updateAndReturnRegistry(parent, childWillBeComposed) } internal class SavedStateHolder(private val key: String) : CompositionLifecycleObserver { - private var parent: UiSavedStateRegistry? = null - private var isScreenVisible = false - private var values: Map>? = null - private var registry: UiSavedStateRegistry = createRegistry() - private var valueProvider: () -> Any? = { - if (isScreenVisible) { - // Save the screen if it is visible right now. If it is invisible, then it's - // values were already saved upon leaving the screen. - values = registry.performSave() - } - values + private var parent: UiSavedStateRegistry? = null + private var isScreenVisible = false + private var values: Map>? = null + private var registry: UiSavedStateRegistry = createRegistry() + private var valueProvider: () -> Any? = { + if (isScreenVisible) { + // Save the screen if it is visible right now. If it is invisible, then it's + // values were already saved upon leaving the screen. + values = registry.performSave() } + values + } - /** - * Manages the visibility of the screen and saves its state whenever [isVisible] transitions - * from true to false, or whenever the Android OS triggers an onSaveInstanceState dispatch. - * - * Returns a [UiSavedStateRegistry] containing the most recently saved values. - */ - @Suppress("UNCHECKED_CAST") - fun updateAndReturnRegistry( - parent: UiSavedStateRegistry?, - isVisible: Boolean - ): UiSavedStateRegistry { - // When values is null, try restore any previously saved values (or fallback to an empty - // map). Once values is non-null, it'll hold the all the latest saved values for the screen. - values = values ?: parent?.consumeRestored(key) as Map>? ?: emptyMap() + /** + * Manages the visibility of the screen and saves its state whenever [isVisible] transitions + * from true to false, or whenever the Android OS triggers an onSaveInstanceState dispatch. + * + * Returns a [UiSavedStateRegistry] containing the most recently saved values. + */ + @Suppress("UNCHECKED_CAST") + fun updateAndReturnRegistry( + parent: UiSavedStateRegistry?, + isVisible: Boolean + ): UiSavedStateRegistry { + // When values is null, try restore any previously saved values (or fallback to an empty + // map). Once values is non-null, it'll hold the all the latest saved values for the screen. + values = values ?: parent?.consumeRestored(key) as Map>? ?: emptyMap() - val oldParent = this.parent - this.parent = parent + val oldParent = this.parent + this.parent = parent - // Use an identity comparison here for safety because UiSavedStateRegistry is an interface - // and custom implementations might have their own custom equals implementation. And if we - // call unregisterProvider on an UiSavedStateRegistry where `key` isn't already registered, - // then it'll crash. - if (parent !== oldParent) { - oldParent?.unregisterProvider(key, valueProvider) - parent?.registerProvider(key, valueProvider) - } - - if (isVisible == this.isScreenVisible) return registry - this.isScreenVisible = isVisible + // Use an identity comparison here for safety because UiSavedStateRegistry is an interface + // and custom implementations might have their own custom equals implementation. And if we + // call unregisterProvider on an UiSavedStateRegistry where `key` isn't already registered, + // then it'll crash. + if (parent !== oldParent) { + oldParent?.unregisterProvider(key, valueProvider) + parent?.registerProvider(key, valueProvider) + } - if (!isVisible) { - // Perform save on this screen just before it leaves the composition. - values = registry.performSave() - } else { - // Recreate the registry so the most recently-saved values will be used to restore. - // The UiSavedStateRegistry function makes a defensive copy of the passed-in map, so - // it needs to be recreated on every restoration. - registry = createRegistry() - } + if (isVisible == this.isScreenVisible) return registry + this.isScreenVisible = isVisible - return registry + if (!isVisible) { + // Perform save on this screen just before it leaves the composition. + values = registry.performSave() + } else { + // Recreate the registry so the most recently-saved values will be used to restore. + // The UiSavedStateRegistry function makes a defensive copy of the passed-in map, so + // it needs to be recreated on every restoration. + registry = createRegistry() } - override fun onEnter() { - // No-op - } + return registry + } - override fun onLeave() { - parent?.unregisterProvider(key, valueProvider) - } + override fun onEnter() { + // No-op + } - private fun createRegistry(): UiSavedStateRegistry { - // If there's no registry available, then we won't be restored anyway so there are no - // serializability restrictions on saved values. - val canBeSaved: (Any) -> Boolean = parent?.let { it::canBeSaved } ?: { true } - return UiSavedStateRegistry(values, canBeSaved) - } + override fun onLeave() { + parent?.unregisterProvider(key, valueProvider) + } + + private fun createRegistry(): UiSavedStateRegistry { + // If there's no registry available, then we won't be restored anyway so there are no + // serializability restrictions on saved values. + val canBeSaved: (Any) -> Boolean = parent?.let { it::canBeSaved } ?: { true } + return UiSavedStateRegistry(values, canBeSaved) + } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt index f47f3c5..5868bb0 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt @@ -3,7 +3,11 @@ package com.zachklipp.compose.backstack import androidx.compose.foundation.Box -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.gesture.DragObserver @@ -33,39 +37,39 @@ import androidx.compose.ui.gesture.scaleGestureFilter */ @Composable fun InspectionGestureDetector( - enabled: Boolean, - children: @Composable() (InspectionParams?) -> Unit + enabled: Boolean, + children: @Composable() (InspectionParams?) -> Unit ) { - var inspectionParams: InspectionParams by remember { mutableStateOf(InspectionParams()) } + var inspectionParams: InspectionParams by remember { mutableStateOf(InspectionParams()) } - val scaleObserver = remember(enabled) { - object : ScaleObserver { - override fun onScale(scaleFactor: Float) { - if (!enabled) return - inspectionParams = inspectionParams.copy( - scale = inspectionParams.scale * scaleFactor - ).constrained() - } - } + val scaleObserver = remember(enabled) { + object : ScaleObserver { + override fun onScale(scaleFactor: Float) { + if (!enabled) return + inspectionParams = inspectionParams.copy( + scale = inspectionParams.scale * scaleFactor + ).constrained() + } } - val dragObserver = remember(enabled) { - object : DragObserver { - override fun onDrag(dragDistance: Offset): Offset { - if (!enabled) return Offset.Zero - inspectionParams = inspectionParams.copy( - // Dragging left-and-right rotates around the vertical Y axis. - rotationYDegrees = inspectionParams.rotationYDegrees + (dragDistance.x / 5f) - ).constrained() - return dragDistance - } - } + } + val dragObserver = remember(enabled) { + object : DragObserver { + override fun onDrag(dragDistance: Offset): Offset { + if (!enabled) return Offset.Zero + inspectionParams = inspectionParams.copy( + // Dragging left-and-right rotates around the vertical Y axis. + rotationYDegrees = inspectionParams.rotationYDegrees + (dragDistance.x / 5f) + ).constrained() + return dragDistance + } } + } - Box( - modifier = Modifier - .scaleGestureFilter(scaleObserver = scaleObserver) - .dragGestureFilter(dragObserver = dragObserver) - ) { - children(inspectionParams.takeIf { enabled }) - } + Box( + modifier = Modifier + .scaleGestureFilter(scaleObserver = scaleObserver) + .dragGestureFilter(dragObserver = dragObserver) + ) { + children(inspectionParams.takeIf { enabled }) + } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt index 7f8aa87..7981e61 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/PercentageLayoutOffset.kt @@ -10,24 +10,24 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize internal class PercentageLayoutOffset(offset: Float) : LayoutModifier { - private val offset = offset.coerceIn(-1f..1f) + private val offset = offset.coerceIn(-1f..1f) - override fun MeasureScope.measure( - measurable: Measurable, - constraints: Constraints - ): MeasureScope.MeasureResult { - val placeable = measurable.measure(constraints) - return layout(placeable.width, placeable.height) { - placeable.place(offsetPosition(IntSize(placeable.width, placeable.height))) - } + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureScope.MeasureResult { + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + placeable.place(offsetPosition(IntSize(placeable.width, placeable.height))) } + } - @VisibleForTesting(otherwise = PRIVATE) - internal fun offsetPosition(containerSize: IntSize) = IntOffset( - // RTL is handled automatically by place. - x = (containerSize.width * offset).toInt(), - y = 0 - ) + @VisibleForTesting(otherwise = PRIVATE) + internal fun offsetPosition(containerSize: IntSize) = IntOffset( + // RTL is handled automatically by place. + x = (containerSize.width * offset).toInt(), + y = 0 + ) - override fun toString(): String = "${this::class.java.simpleName}(offset=$offset)" + override fun toString(): String = "${this::class.java.simpleName}(offset=$offset)" } diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt index fa0f474..f241606 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/BackstackTest.kt @@ -6,157 +6,160 @@ import org.junit.Test class BackstackTest { - @Test - fun `calculateRegularModifier handles single screen`() { - assertScreenProperties( - 1, 1f, - ScreenProperties( - isVisible = true, - modifier = Modifier - ) + @Test + fun `calculateRegularModifier handles single screen`() { + assertScreenProperties( + 1, 1f, + ScreenProperties( + isVisible = true, + modifier = Modifier ) - } + ) + } - @Test - fun `calculateRegularModifier handles two screens`() { - val count = 2 - assertScreenProperties( - count, 1f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = Modifier - ) + @Test + fun `calculateRegularModifier handles two screens`() { + val count = 2 + assertScreenProperties( + count, 1f, + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER + ), + ScreenProperties( + isVisible = true, + modifier = Modifier ) + ) - assertScreenProperties( - count, .75f, - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = true) - ) + assertScreenProperties( + count, .75f, + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .25f, isTop = false) + ), + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .75f, isTop = true) ) + ) - assertScreenProperties( - count, .25f, - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = true) - ) + assertScreenProperties( + count, .25f, + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .75f, isTop = false) + ), + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .25f, isTop = true) ) + ) - assertScreenProperties( - count, 0f, - ScreenProperties( - isVisible = true, - modifier = Modifier - ), - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ) + assertScreenProperties( + count, 0f, + ScreenProperties( + isVisible = true, + modifier = Modifier + ), + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER ) - } + ) + } - @Test - fun `calculateRegularModifier handles three screens`() { - val count = 3 - assertScreenProperties( - count, 1f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = Modifier - ) + @Test + fun `calculateRegularModifier handles three screens`() { + val count = 3 + assertScreenProperties( + count, 1f, + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER + ), + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER + ), + ScreenProperties( + isVisible = true, + modifier = Modifier ) + ) - assertScreenProperties( - count, .75f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = true) - ) + assertScreenProperties( + count, .75f, + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER + ), + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .25f, isTop = false) + ), + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .75f, isTop = true) ) + ) - assertScreenProperties( - count, .25f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .75f, isTop = false) - ), - ScreenProperties( - isVisible = true, - modifier = TestModifier(visibility = .25f, isTop = true) - ) + assertScreenProperties( + count, .25f, + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER + ), + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .75f, isTop = false) + ), + ScreenProperties( + isVisible = true, + modifier = TestModifier(visibility = .25f, isTop = true) ) + ) - assertScreenProperties( - count, 0f, - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ), - ScreenProperties( - isVisible = true, - modifier = Modifier - ), - ScreenProperties( - isVisible = false, - modifier = HIDDEN_MODIFIER - ) + assertScreenProperties( + count, 0f, + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER + ), + ScreenProperties( + isVisible = true, + modifier = Modifier + ), + ScreenProperties( + isVisible = false, + modifier = HIDDEN_MODIFIER ) - } + ) + } - private fun assertScreenProperties( - count: Int, - progress: Float, - vararg expectedProperties: ScreenProperties - ) { - require(count > 0) - require(expectedProperties.size == count) + private fun assertScreenProperties( + count: Int, + progress: Float, + vararg expectedProperties: ScreenProperties + ) { + require(count > 0) + require(expectedProperties.size == count) - for (index in 0 until count) { - val result = calculateRegularModifier(TestTransition, index, count, progress) - assertThat(result).isEqualTo(expectedProperties[index]) - } + for (index in 0 until count) { + val result = calculateRegularModifier(TestTransition, index, count, progress) + assertThat(result).isEqualTo(expectedProperties[index]) } + } - private object TestTransition : BackstackTransition { - override fun modifierForScreen( - visibility: Float, - isTop: Boolean - ): Modifier = TestModifier(visibility, isTop) - } + private object TestTransition : BackstackTransition { + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier = TestModifier(visibility, isTop) + } - private data class TestModifier(val visibility: Float, val isTop: Boolean) : Modifier.Element + private data class TestModifier( + val visibility: Float, + val isTop: Boolean + ) : Modifier.Element } diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt index e6e5e5d..f7017db 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/IntOffsetSubject.kt @@ -7,19 +7,22 @@ import com.google.common.truth.Subject.Factory import com.google.common.truth.Truth.assertAbout class IntOffsetSubject( - metadata: FailureMetadata, - private val actual: IntOffset + metadata: FailureMetadata, + private val actual: IntOffset ) : Subject(metadata, actual) { - fun isEqualTo(x: Int, y: Int) = - check("IntPxPosition(x, y)").that(actual).isEqualTo(IntOffset(x, y)) + fun isEqualTo( + x: Int, + y: Int + ) = + check("IntPxPosition(x, y)").that(actual).isEqualTo(IntOffset(x, y)) - companion object { - @JvmStatic - fun assertThat(actual: IntOffset?) = assertAbout(intPxPositions()).that(actual) + companion object { + @JvmStatic + fun assertThat(actual: IntOffset?) = assertAbout(intPxPositions()).that(actual) - @JvmStatic - fun intPxPositions(): Factory = - Factory { metadata, actual -> IntOffsetSubject(metadata, actual) } - } + @JvmStatic + fun intPxPositions(): Factory = + Factory { metadata, actual -> IntOffsetSubject(metadata, actual) } + } } diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt index 09cc77d..ebde215 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/PercentageLayoutOffsetTest.kt @@ -13,40 +13,40 @@ import org.junit.Test class PercentageLayoutOffsetTest { - private val containerSize = IntSize(100, 100) - - private var isTop = false - private var transitionDirection = Forward - private var layoutDirection = LayoutDirection.Ltr - - @Test - fun `modifies position when going backwards`() { - isTop = false - transitionDirection = Backward - layoutDirection = LayoutDirection.Ltr - assertThat(Slide.applyModifiedPosition(0f)).isEqualTo(-100, 0) - assertThat(Slide.applyModifiedPosition(.25f)).isEqualTo(-75, 0) - assertThat(Slide.applyModifiedPosition(.5f)).isEqualTo(-50, 0) - assertThat(Slide.applyModifiedPosition(.75f)).isEqualTo(-25, 0) - assertThat(Slide.applyModifiedPosition(1f)).isEqualTo(0, 0) - } - - @Test - fun `modifies position when top going forwards`() { - isTop = true - transitionDirection = Forward - layoutDirection = LayoutDirection.Ltr - assertThat(Slide.applyModifiedPosition(0f)).isEqualTo(100, 0) - assertThat(Slide.applyModifiedPosition(.25f)).isEqualTo(75, 0) - assertThat(Slide.applyModifiedPosition(.5f)).isEqualTo(50, 0) - assertThat(Slide.applyModifiedPosition(.75f)).isEqualTo(25, 0) - assertThat(Slide.applyModifiedPosition(1f)).isEqualTo(0, 0) - } - - private fun BackstackTransition.applyModifiedPosition( - visibility: Float - ): IntOffset { - val modifier = modifierForScreen(visibility, isTop) as PercentageLayoutOffset - return modifier.offsetPosition(containerSize) - } + private val containerSize = IntSize(100, 100) + + private var isTop = false + private var transitionDirection = Forward + private var layoutDirection = LayoutDirection.Ltr + + @Test + fun `modifies position when going backwards`() { + isTop = false + transitionDirection = Backward + layoutDirection = LayoutDirection.Ltr + assertThat(Slide.applyModifiedPosition(0f)).isEqualTo(-100, 0) + assertThat(Slide.applyModifiedPosition(.25f)).isEqualTo(-75, 0) + assertThat(Slide.applyModifiedPosition(.5f)).isEqualTo(-50, 0) + assertThat(Slide.applyModifiedPosition(.75f)).isEqualTo(-25, 0) + assertThat(Slide.applyModifiedPosition(1f)).isEqualTo(0, 0) + } + + @Test + fun `modifies position when top going forwards`() { + isTop = true + transitionDirection = Forward + layoutDirection = LayoutDirection.Ltr + assertThat(Slide.applyModifiedPosition(0f)).isEqualTo(100, 0) + assertThat(Slide.applyModifiedPosition(.25f)).isEqualTo(75, 0) + assertThat(Slide.applyModifiedPosition(.5f)).isEqualTo(50, 0) + assertThat(Slide.applyModifiedPosition(.75f)).isEqualTo(25, 0) + assertThat(Slide.applyModifiedPosition(1f)).isEqualTo(0, 0) + } + + private fun BackstackTransition.applyModifiedPosition( + visibility: Float + ): IntOffset { + val modifier = modifierForScreen(visibility, isTop) as PercentageLayoutOffset + return modifier.offsetPosition(containerSize) + } } diff --git a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt index 3274ae2..27bf553 100644 --- a/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt +++ b/compose-backstack/src/test/java/com/zachklipp/compose/backstack/SavedStateHolderTest.kt @@ -6,124 +6,124 @@ import org.junit.Test class SavedStateHolderTest { - @Test - fun `saves and restores`() { - val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - val valueProvider = { "value" } - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - registry.registerProvider("ck", valueProvider) - registry = holder.updateAndReturnRegistry(parent, isVisible = false) - registry.unregisterProvider("ck", valueProvider) - registry = holder.updateAndReturnRegistry(parent = parent, isVisible = true) - - assertThat(registry.consumeRestored("ck")).isEqualTo("value") - } - - @Test - fun `restores from initial values`() { - val restoredValues = mutableMapOf("pk" to listOf(mapOf("ck" to listOf("value")))) - val parent = UiSavedStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - val registry = holder.updateAndReturnRegistry(parent = parent, isVisible = true) - - assertThat(registry.consumeRestored("ck")).isEqualTo("value") - } - - @Test - fun `doesn't save unregistered providers`() { - val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - val valueProvider = { "value" } - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - registry.registerProvider("key", valueProvider) - registry.unregisterProvider("key", valueProvider) - holder.updateAndReturnRegistry(parent, isVisible = false) - registry = holder.updateAndReturnRegistry(parent, isVisible = true) - - assertThat(registry.consumeRestored("key")).isNull() - } - - @Test - fun `preserves unrestored values from previous save`() { - val restoredValues = mutableMapOf("pk" to listOf(mapOf("old key" to listOf("old value")))) - val parent = UiSavedStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - holder.updateAndReturnRegistry(parent, isVisible = true) - // Performs the save without having consumed "old key". - holder.updateAndReturnRegistry(parent, isVisible = false) - val registry = holder.updateAndReturnRegistry(parent, isVisible = true) - - assertThat(registry.consumeRestored("old key")).isEqualTo("old value") - } - - @Test - fun `cleans up restored values from previous save`() { - val restoredValues = mutableMapOf("pk" to listOf(mapOf("old key" to listOf("old value")))) - val parent = UiSavedStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - val oldValue = registry.consumeRestored("old key") - holder.updateAndReturnRegistry(parent, isVisible = false) - registry = holder.updateAndReturnRegistry(parent, isVisible = true) - - assertThat(oldValue).isEqualTo("old value") - assertThat(registry.consumeRestored("old key")).isNull() - } - - @Test - fun `parent saves contain values from the currently visible screen`() { - val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - - val registry = holder.updateAndReturnRegistry(parent, isVisible = true) - registry.registerProvider("ck") { "value" } - - val values = parent.performSave() - assertThat(values["pk"]).isEqualTo(listOf(mapOf("ck" to listOf("value")))) - } - - @Test - fun `parent saves contain values from non-visible screens`() { - val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) - val holder = SavedStateHolder("pk") - val valueProvider = { "value" } - - var registry = holder.updateAndReturnRegistry(parent, isVisible = true) - registry.registerProvider("ck", valueProvider) - registry = holder.updateAndReturnRegistry(parent, isVisible = false) - registry.unregisterProvider("ck", valueProvider) - - val values = parent.performSave() - assertThat(values["pk"]).isEqualTo(listOf(mapOf("ck" to listOf("value")))) - } - - @Test - fun `parent saves contain values from nested children`() { - val topStateHolder = SavedStateHolder("pk1") - val middleStateHolder = SavedStateHolder("pk2") - - val topRegistry = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) - val middleRegistry = topStateHolder.updateAndReturnRegistry(topRegistry, isVisible = true) - val bottomRegistry = - middleStateHolder.updateAndReturnRegistry(middleRegistry, isVisible = true) - - middleRegistry.registerProvider("ck1") { "middle value" } - bottomRegistry.registerProvider("ck2") { "bottom value" } - - val values = topRegistry.performSave() - assertThat(values["pk1"]).isEqualTo( - listOf( - mapOf( - "ck1" to listOf("middle value"), - "pk2" to listOf(mapOf("ck2" to listOf("bottom value"))) - ) + @Test + fun `saves and restores`() { + val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + val valueProvider = { "value" } + + var registry = holder.updateAndReturnRegistry(parent, isVisible = true) + registry.registerProvider("ck", valueProvider) + registry = holder.updateAndReturnRegistry(parent, isVisible = false) + registry.unregisterProvider("ck", valueProvider) + registry = holder.updateAndReturnRegistry(parent = parent, isVisible = true) + + assertThat(registry.consumeRestored("ck")).isEqualTo("value") + } + + @Test + fun `restores from initial values`() { + val restoredValues = mutableMapOf("pk" to listOf(mapOf("ck" to listOf("value")))) + val parent = UiSavedStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + + val registry = holder.updateAndReturnRegistry(parent = parent, isVisible = true) + + assertThat(registry.consumeRestored("ck")).isEqualTo("value") + } + + @Test + fun `doesn't save unregistered providers`() { + val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + val valueProvider = { "value" } + + var registry = holder.updateAndReturnRegistry(parent, isVisible = true) + registry.registerProvider("key", valueProvider) + registry.unregisterProvider("key", valueProvider) + holder.updateAndReturnRegistry(parent, isVisible = false) + registry = holder.updateAndReturnRegistry(parent, isVisible = true) + + assertThat(registry.consumeRestored("key")).isNull() + } + + @Test + fun `preserves unrestored values from previous save`() { + val restoredValues = mutableMapOf("pk" to listOf(mapOf("old key" to listOf("old value")))) + val parent = UiSavedStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + + holder.updateAndReturnRegistry(parent, isVisible = true) + // Performs the save without having consumed "old key". + holder.updateAndReturnRegistry(parent, isVisible = false) + val registry = holder.updateAndReturnRegistry(parent, isVisible = true) + + assertThat(registry.consumeRestored("old key")).isEqualTo("old value") + } + + @Test + fun `cleans up restored values from previous save`() { + val restoredValues = mutableMapOf("pk" to listOf(mapOf("old key" to listOf("old value")))) + val parent = UiSavedStateRegistry(restoredValues = restoredValues, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + + var registry = holder.updateAndReturnRegistry(parent, isVisible = true) + val oldValue = registry.consumeRestored("old key") + holder.updateAndReturnRegistry(parent, isVisible = false) + registry = holder.updateAndReturnRegistry(parent, isVisible = true) + + assertThat(oldValue).isEqualTo("old value") + assertThat(registry.consumeRestored("old key")).isNull() + } + + @Test + fun `parent saves contain values from the currently visible screen`() { + val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + + val registry = holder.updateAndReturnRegistry(parent, isVisible = true) + registry.registerProvider("ck") { "value" } + + val values = parent.performSave() + assertThat(values["pk"]).isEqualTo(listOf(mapOf("ck" to listOf("value")))) + } + + @Test + fun `parent saves contain values from non-visible screens`() { + val parent = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) + val holder = SavedStateHolder("pk") + val valueProvider = { "value" } + + var registry = holder.updateAndReturnRegistry(parent, isVisible = true) + registry.registerProvider("ck", valueProvider) + registry = holder.updateAndReturnRegistry(parent, isVisible = false) + registry.unregisterProvider("ck", valueProvider) + + val values = parent.performSave() + assertThat(values["pk"]).isEqualTo(listOf(mapOf("ck" to listOf("value")))) + } + + @Test + fun `parent saves contain values from nested children`() { + val topStateHolder = SavedStateHolder("pk1") + val middleStateHolder = SavedStateHolder("pk2") + + val topRegistry = UiSavedStateRegistry(restoredValues = null, canBeSaved = { true }) + val middleRegistry = topStateHolder.updateAndReturnRegistry(topRegistry, isVisible = true) + val bottomRegistry = + middleStateHolder.updateAndReturnRegistry(middleRegistry, isVisible = true) + + middleRegistry.registerProvider("ck1") { "middle value" } + bottomRegistry.registerProvider("ck2") { "bottom value" } + + val values = topRegistry.performSave() + assertThat(values["pk1"]).isEqualTo( + listOf( + mapOf( + "ck1" to listOf("middle value"), + "pk2" to listOf(mapOf("ck2" to listOf("bottom value"))) ) ) - } + ) + } } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 1fb121b..b97d16b 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,21 +1,21 @@ plugins { - id("com.android.application") - id("default-android-config") - kotlin("android") + id("com.android.application") + id("default-android-config") + kotlin("android") } android { - defaultConfig { - applicationId = "com.zachklipp.compose.backstack.sample" - } + defaultConfig { + applicationId = "com.zachklipp.compose.backstack.sample" + } } dependencies { - implementation(project(":compose-backstack-viewer")) - implementation(Dependencies.AndroidX.appcompat) - implementation(Dependencies.Compose.foundation) - implementation(Dependencies.Compose.util) + implementation(project(":compose-backstack-viewer")) + implementation(Dependencies.AndroidX.appcompat) + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Compose.util) - androidTestImplementation(Dependencies.AndroidX.junitExt) - androidTestImplementation(Dependencies.Compose.test) + androidTestImplementation(Dependencies.AndroidX.junitExt) + androidTestImplementation(Dependencies.Compose.test) } diff --git a/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt b/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt index d851009..a83f735 100644 --- a/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt +++ b/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt @@ -11,16 +11,16 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SampleAppTest { - @get:Rule - val compose = createAndroidComposeRule() + @get:Rule + val compose = createAndroidComposeRule() - @Test - fun launches() { - onNodeWithSubstring("Slow animations") - } + @Test + fun launches() { + onNodeWithSubstring("Slow animations") + } - @Test - fun showsCounter() { - onNodeWithSubstring("Counter:").assertIsDisplayed() - } + @Test + fun showsCounter() { + onNodeWithSubstring("Counter:").assertIsDisplayed() + } } diff --git a/sample/src/main/java/com/zachklipp/compose/backstack/sample/ComposeBackstackActivity.kt b/sample/src/main/java/com/zachklipp/compose/backstack/sample/ComposeBackstackActivity.kt index c8349bc..d856d46 100644 --- a/sample/src/main/java/com/zachklipp/compose/backstack/sample/ComposeBackstackActivity.kt +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/ComposeBackstackActivity.kt @@ -6,18 +6,18 @@ import androidx.compose.ui.platform.setContent import com.zachklipp.compose.backstack.viewer.BackstackViewerApp class ComposeBackstackActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - BackstackViewerApp( - namedCustomTransitions = listOf("Fancy" to FancyTransition), - prefabBackstacks = listOf( - listOf("one"), - listOf("one", "two"), - listOf("one", "two", "three"), - listOf("two", "one") - ) - ) - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + BackstackViewerApp( + namedCustomTransitions = listOf("Fancy" to FancyTransition), + prefabBackstacks = listOf( + listOf("one"), + listOf("one", "two"), + listOf("one", "two", "three"), + listOf("two", "one") + ) + ) } + } } diff --git a/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt b/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt index 631ce51..7ae9d6e 100644 --- a/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/FancyTransition.kt @@ -13,20 +13,20 @@ import kotlin.math.pow * with some additional math and other transformations. */ object FancyTransition : BackstackTransition { - override fun modifierForScreen( - visibility: Float, - isTop: Boolean - ): Modifier { - return if (isTop) { - // Start sliding in from the middle to reduce the motion a bit. - val slideVisibility = lerp(.5f, 1f, visibility) - Slide.modifierForScreen(slideVisibility, isTop) - .then(Crossfade.modifierForScreen(visibility, isTop)) - } else { - // Move the non-top screen back, but only a little. - val scaleVisibility = lerp(.9f, 1f, visibility) - Modifier.drawLayer(scaleX = scaleVisibility, scaleY = scaleVisibility) - .then(Crossfade.modifierForScreen(visibility.pow(.5f), isTop)) - } + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier { + return if (isTop) { + // Start sliding in from the middle to reduce the motion a bit. + val slideVisibility = lerp(.5f, 1f, visibility) + Slide.modifierForScreen(slideVisibility, isTop) + .then(Crossfade.modifierForScreen(visibility, isTop)) + } else { + // Move the non-top screen back, but only a little. + val scaleVisibility = lerp(.9f, 1f, visibility) + Modifier.drawLayer(scaleX = scaleVisibility, scaleY = scaleVisibility) + .then(Crossfade.modifierForScreen(visibility.pow(.5f), isTop)) } + } } From fa25d0072652b6de2d9e89d2a55b983d02776f17 Mon Sep 17 00:00:00 2001 From: Zach Klippenstein Date: Sat, 10 Oct 2020 14:34:03 -0700 Subject: [PATCH 2/2] Upgrade Compose and AGP to latest versions. --- .../java/default-android-config.gradle.kts | 1 - .../src/main/resources/versions.properties | 4 +- compose-backstack-viewer/build.gradle.kts | 7 ++ .../backstack/viewer/BackstackViewerTest.kt | 78 +++++++++---------- .../backstack/viewer/BackstackViewerApp.kt | 35 ++++++--- .../compose/backstack/viewer/Spinner.kt | 7 +- .../backstack/BackstackComposableTest.kt | 30 +++---- .../zachklipp/compose/backstack/Backstack.kt | 11 ++- .../backstack/InspectionGestureDetector.kt | 4 +- sample/build.gradle.kts | 4 + .../compose/backstack/sample/SampleAppTest.kt | 6 +- 11 files changed, 106 insertions(+), 81 deletions(-) diff --git a/buildSrc/src/main/java/default-android-config.gradle.kts b/buildSrc/src/main/java/default-android-config.gradle.kts index d913cf7..69dea79 100644 --- a/buildSrc/src/main/java/default-android-config.gradle.kts +++ b/buildSrc/src/main/java/default-android-config.gradle.kts @@ -8,7 +8,6 @@ plugins { configure { compileSdkVersion(Versions.targetSdk) - buildToolsVersion = "29.0.2" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/buildSrc/src/main/resources/versions.properties b/buildSrc/src/main/resources/versions.properties index 85b5a9e..3e9c619 100644 --- a/buildSrc/src/main/resources/versions.properties +++ b/buildSrc/src/main/resources/versions.properties @@ -1,7 +1,7 @@ # -SNAPSHOT will automatically be appended. Pass -PisRelease=true to gradlew to release. releaseVersion=0.6.0 -androidGradlePluginVersion=4.2.0-alpha10 +androidGradlePluginVersion=4.2.0-alpha13 kotlinVersion=1.4.10 dokkaVersion=1.4.0 -composeDevVersion=alpha02 +composeDevVersion=alpha04 diff --git a/compose-backstack-viewer/build.gradle.kts b/compose-backstack-viewer/build.gradle.kts index 33e5f27..2e6bd4f 100644 --- a/compose-backstack-viewer/build.gradle.kts +++ b/compose-backstack-viewer/build.gradle.kts @@ -6,6 +6,13 @@ plugins { id("publish-to-maven") } +android { + lintOptions { + // Workaround for lint bug. + disable += "InvalidFragmentVersionForActivityResult" + } +} + dependencies { compileOnly(Dependencies.Compose.tooling) diff --git a/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt b/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt index 7e7981f..827217c 100644 --- a/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt +++ b/compose-backstack-viewer/src/androidTest/java/com/zachklipp/compose/backstack/viewer/BackstackViewerTest.kt @@ -26,15 +26,15 @@ class BackstackViewerTest { BackstackViewerApp() } - onNodeWithText("Slide Transition").assertIsDisplayed() - onNodeWithSubstring("Slow animations").assertIsDisplayed() + compose.onNodeWithText("Slide Transition").assertIsDisplayed() + compose.onNodeWithSubstring("Slow animations").assertIsDisplayed() - onNodeWithText("one").assertIsSelected() - onNodeWithText("one, two").assertIsNotSelected() - onNodeWithText("one, two, three").assertIsNotSelected() + compose.onNodeWithText("one").assertIsSelected() + compose.onNodeWithText("one, two").assertIsNotSelected() + compose.onNodeWithText("one, two, three").assertIsNotSelected() - onNodeWithText("Screen one").assertIsDisplayed() - onNodeWithSubstring("Counter:").assertIsDisplayed() + compose.onNodeWithText("Screen one").assertIsDisplayed() + compose.onNodeWithSubstring("Counter:").assertIsDisplayed() } @Test @@ -43,8 +43,8 @@ class BackstackViewerTest { BackstackViewerApp() } - onNodeWithTag(backTestTag("one")).assertHasClickAction().performClick() - onNodeWithText("Screen one").assertIsDisplayed() + compose.onNodeWithTag(backTestTag("one")).assertHasClickAction().performClick() + compose.onNodeWithText("Screen one").assertIsDisplayed() } @Test @@ -53,16 +53,16 @@ class BackstackViewerTest { BackstackViewerApp() } - onNodeWithText("Screen one").assertIsDisplayed() - onNodeWithText("Screen two").assertDoesNotExist() + compose.onNodeWithText("Screen one").assertIsDisplayed() + compose.onNodeWithText("Screen two").assertDoesNotExist() - onNodeWithText("one, two") - .assertIsNotSelected() - .performClick() - .assertIsSelected() + compose.onNodeWithText("one, two") + .assertIsNotSelected() + .performClick() + .assertIsSelected() - onNodeWithText("Screen one").assertDoesNotExist() - onNodeWithText("Screen two").assertIsDisplayed() + compose.onNodeWithText("Screen one").assertDoesNotExist() + compose.onNodeWithText("Screen two").assertIsDisplayed() } @Test @@ -71,18 +71,18 @@ class BackstackViewerTest { BackstackViewerApp() } - onNodeWithText("Screen one").assertIsDisplayed() - onNodeWithText("Screen two").assertDoesNotExist() - onNodeWithText("Screen three").assertDoesNotExist() + compose.onNodeWithText("Screen one").assertIsDisplayed() + compose.onNodeWithText("Screen two").assertDoesNotExist() + compose.onNodeWithText("Screen three").assertDoesNotExist() - onNodeWithText("one, two, three") - .assertIsNotSelected() - .performClick() - .assertIsSelected() + compose.onNodeWithText("one, two, three") + .assertIsNotSelected() + .performClick() + .assertIsSelected() - onNodeWithText("Screen one").assertDoesNotExist() - onNodeWithText("Screen two").assertDoesNotExist() - onNodeWithText("Screen three").assertIsDisplayed() + compose.onNodeWithText("Screen one").assertDoesNotExist() + compose.onNodeWithText("Screen two").assertDoesNotExist() + compose.onNodeWithText("Screen three").assertIsDisplayed() } @Test @@ -91,16 +91,16 @@ class BackstackViewerTest { BackstackViewerApp() } - onNodeWithText("one, two, three").performClick().assertIsSelected() - onNodeWithText("Screen three").assertIsDisplayed() + compose.onNodeWithText("one, two, three").performClick().assertIsSelected() + compose.onNodeWithText("Screen three").assertIsDisplayed() - onNodeWithTag(backTestTag("three")).performClick() - onNodeWithText("one, two").assertIsSelected() - onNodeWithText("Screen three").assertDoesNotExist() + compose.onNodeWithTag(backTestTag("three")).performClick() + compose.onNodeWithText("one, two").assertIsSelected() + compose.onNodeWithText("Screen three").assertDoesNotExist() - onNodeWithTag(backTestTag("two")).performClick() - onNodeWithText("one").assertIsSelected() - onNodeWithText("Screen two").assertDoesNotExist() + compose.onNodeWithTag(backTestTag("two")).performClick() + compose.onNodeWithText("one").assertIsSelected() + compose.onNodeWithText("Screen two").assertDoesNotExist() } @Test @@ -109,9 +109,9 @@ class BackstackViewerTest { BackstackViewerApp() } - onNodeWithTag(addTestTag("one")).assertHasClickAction().performClick() - onNodeWithText("Screen one+").assertIsDisplayed() - onNodeWithTag(backTestTag("one+")).assertHasClickAction().performClick() - onNodeWithText("Screen one+").assertDoesNotExist() + compose.onNodeWithTag(addTestTag("one")).assertHasClickAction().performClick() + compose.onNodeWithText("Screen one+").assertIsDisplayed() + compose.onNodeWithTag(backTestTag("one+")).assertHasClickAction().performClick() + compose.onNodeWithText("Screen one+").assertDoesNotExist() } } diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt index f324121..84e35d9 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/BackstackViewerApp.kt @@ -5,10 +5,25 @@ import android.content.ContextWrapper import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.compose.animation.core.TweenSpec -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.Text +import androidx.compose.foundation.border +import androidx.compose.foundation.currentTextStyle +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.preferredHeight import androidx.compose.foundation.selection.selectable -import androidx.compose.material.* +import androidx.compose.material.ListItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.onCommit @@ -61,8 +76,8 @@ private fun BackstackViewerAppPreview() { */ @Composable fun BackstackViewerApp( - namedCustomTransitions: List> = emptyList(), - prefabBackstacks: List>? = null + namedCustomTransitions: List> = emptyList(), + prefabBackstacks: List>? = null ) = key(namedCustomTransitions, prefabBackstacks) { val model = AppModel.create( namedTransitions = namedCustomTransitions + BUILTIN_BACKSTACK_TRANSITIONS, @@ -96,12 +111,12 @@ private fun AppControls(model: AppModel) { } Row { - Text("Slow animations: ", modifier = Modifier.gravity(Alignment.CenterVertically)) + Text("Slow animations: ", modifier = Modifier.align(Alignment.CenterVertically)) Switch(model.slowAnimations, onCheckedChange = { model.slowAnimations = it }) } Row { - Text("Inspect (pinch + drag): ", modifier = Modifier.gravity(Alignment.CenterVertically)) + Text("Inspect (pinch + drag): ", modifier = Modifier.align(Alignment.CenterVertically)) Switch(model.inspectionEnabled, onCheckedChange = { model.inspectionEnabled = it }) } @@ -156,9 +171,9 @@ private fun AppScreens(model: AppModel) { @Composable private fun RadioButton( - text: String, - selected: Boolean, - onSelect: () -> Unit + text: String, + selected: Boolean, + onSelect: () -> Unit ) { Box( modifier = Modifier.selectable( diff --git a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt index 8014eb3..64339ed 100644 --- a/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt +++ b/compose-backstack-viewer/src/main/java/com/zachklipp/compose/backstack/viewer/Spinner.kt @@ -2,10 +2,11 @@ package com.zachklipp.compose.backstack.viewer -import androidx.compose.foundation.Box + import androidx.compose.foundation.Icon import androidx.compose.foundation.Text import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio @@ -55,7 +56,7 @@ internal fun Spinner( items: List, selectedItem: T, onSelected: (item: T) -> Unit, - drawItem: @Composable() (T) -> Unit + drawItem: @Composable (T) -> Unit ) { if (items.isEmpty()) return @@ -63,7 +64,7 @@ internal fun Spinner( // Always draw the selected item. Row(Modifier.clickable(onClick = { isOpen = !isOpen })) { - Box(modifier = Modifier.weight(1f).gravity(Alignment.CenterVertically)) { + Box(modifier = Modifier.weight(1f).align(Alignment.CenterVertically)) { drawItem(selectedItem) } Icon( diff --git a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt index 774c4de..0915a4a 100644 --- a/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt +++ b/compose-backstack/src/androidTest/java/com/zachklipp/compose/backstack/BackstackComposableTest.kt @@ -64,7 +64,7 @@ class BackstackComposableTest { Backstack(originalBackstack, transition = transition) { Text(it) } } - onNodeWithText("one").assertIsDisplayed() + compose.onNodeWithText("one").assertIsDisplayed() } private fun assertInitialStateWithMultipleScreens(transition: BackstackTransition) { @@ -73,8 +73,8 @@ class BackstackComposableTest { Backstack(originalBackstack, transition = transition) { Text(it) } } - onNodeWithText("two").assertIsDisplayed() - onNodeWithText("one").assertDoesNotExist() + compose.onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText("one").assertDoesNotExist() } private fun assertTransition(transition: BackstackTransition) { @@ -91,34 +91,34 @@ class BackstackComposableTest { } } - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertDoesNotExist() + compose.onNodeWithText("one").assertIsDisplayed() + compose.onNodeWithText("two").assertDoesNotExist() - runOnUiThread { + compose.runOnUiThread { backstack = destinationBackstack } - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertDoesNotExist() + compose.onNodeWithText("one").assertIsDisplayed() + compose.onNodeWithText("two").assertDoesNotExist() setTransitionTime(25) - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText("one").assertIsDisplayed() + compose.onNodeWithText("two").assertIsDisplayed() setTransitionTime(75) - onNodeWithText("one").assertIsDisplayed() - onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText("one").assertIsDisplayed() + compose.onNodeWithText("two").assertIsDisplayed() setTransitionTime(100) - onNodeWithText("one").assertDoesNotExist() - onNodeWithText("two").assertIsDisplayed() + compose.onNodeWithText("one").assertDoesNotExist() + compose.onNodeWithText("two").assertIsDisplayed() } private fun setTransitionTime(time: Long) { - runOnUiThread { + compose.runOnUiThread { clock.clockTimeMillis = time } } diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt index 3b753fb..3721e7f 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt @@ -7,8 +7,7 @@ import androidx.compose.animation.core.AnimationEndReason import androidx.compose.animation.core.AnimationEndReason.TargetReached import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.TweenSpec -import androidx.compose.foundation.Box -import androidx.compose.foundation.layout.Stack +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.* import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistryAmbient import androidx.compose.ui.Modifier @@ -37,7 +36,7 @@ enum class TransitionDirection { */ private data class ScreenWrapper( val key: T, - val transition: @Composable() (progress: Float, @Composable() () -> Unit) -> Unit + val transition: @Composable (progress: Float, @Composable () -> Unit) -> Unit ) internal data class ScreenProperties( @@ -136,7 +135,7 @@ fun Backstack( onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? = null, onTransitionFinished: (() -> Unit)? = null, inspectionParams: InspectionParams? = null, - drawScreen: @Composable() (T) -> Unit + drawScreen: @Composable (T) -> Unit ) { require(backstack.isNotEmpty()) { "Backstack must contain at least 1 screen." } onCommit(backstack) { @@ -255,14 +254,14 @@ fun Backstack( } Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) { - Box(screenProperties.modifier, children = children) + Box(screenProperties.modifier) { children() } } } } } // Actually draw the screens. - Stack(modifier = modifier.clip(RectangleShape)) { + Box(modifier = modifier.clip(RectangleShape)) { activeStackDrawers.forEach { (item, transition) -> // Key is a convenience helper that treats its arguments as @Pivotal. This is how state // preservation is implemented. Even if screens are moved around within the list, as long diff --git a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt index 5868bb0..4c2daa2 100644 --- a/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt +++ b/compose-backstack/src/main/java/com/zachklipp/compose/backstack/InspectionGestureDetector.kt @@ -2,7 +2,7 @@ package com.zachklipp.compose.backstack -import androidx.compose.foundation.Box +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -38,7 +38,7 @@ import androidx.compose.ui.gesture.scaleGestureFilter @Composable fun InspectionGestureDetector( enabled: Boolean, - children: @Composable() (InspectionParams?) -> Unit + children: @Composable (InspectionParams?) -> Unit ) { var inspectionParams: InspectionParams by remember { mutableStateOf(InspectionParams()) } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index b97d16b..61a6140 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -8,6 +8,10 @@ android { defaultConfig { applicationId = "com.zachklipp.compose.backstack.sample" } + lintOptions { + // Workaround lint bug. + disable += "InvalidFragmentVersionForActivityResult" + } } dependencies { diff --git a/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt b/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt index a83f735..872f663 100644 --- a/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt +++ b/sample/src/androidTest/java/com/zachklipp/compose/backstack/sample/SampleAppTest.kt @@ -1,8 +1,8 @@ package com.zachklipp.compose.backstack.sample import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.ui.test.android.createAndroidComposeRule import androidx.ui.test.assertIsDisplayed +import androidx.ui.test.createAndroidComposeRule import androidx.ui.test.onNodeWithSubstring import org.junit.Rule import org.junit.Test @@ -16,11 +16,11 @@ class SampleAppTest { @Test fun launches() { - onNodeWithSubstring("Slow animations") + compose.onNodeWithSubstring("Slow animations") } @Test fun showsCounter() { - onNodeWithSubstring("Counter:").assertIsDisplayed() + compose.onNodeWithSubstring("Counter:").assertIsDisplayed() } }