diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad74e41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build/ +/captures +.externalNativeBuild +.cxx diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe1730d --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# compose-backstack + +Simple library for [Jetpack Compose](https://developer.android.com/jetpack/compose) for rendering +backstacks of screens and animated transitions when the stack changes. It is _not_ a navigation +library, although it is meant to be easy to plug into your navigation library of choice +(e.g. [compose-router](https://github.com/zsoltk/compose-router)), or even just use on its own. + +This library is compatible with Compose dev06. + +## Usage + +The entry point to the library is the `Backstack` composable. + +## Example + +```kotlin + sealed class Screen { + object ContactList: Screen() + data class ContactDetails(val id: String): Screen() + data class EditContact(val id: String): Screen() + } + + data class Navigator( + val push: (Screen) -> Unit, + val pop: () -> Unit + ) + + @Composable fun App() { + var backstack by state { listOf(Screen.ContactList) } + val navigator = remember { + Navigator( + push = { backstack += it }, + pop = { backstack = backstack.dropLast(1) } + ) + } + + Backstack(backstack) { screen -> + when(screen) { + Screen.ContactList -> ShowContactList(navigator) + is Screen.ContactDetails -> ShowContact(screen.id, navigator) + is Screen.EditContact -> ShowEditContact(screen.id, navigator) + } + } + } +``` + +## Samples + +There is a sample app in the `sample` module that demonstrates various transition animations and +the behavior with different backstacks. diff --git a/backstack/build.gradle b/backstack/build.gradle new file mode 100644 index 0000000..155d939 --- /dev/null +++ b/backstack/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android rootProject.ext.defaultAndroidConfig + +dependencies { + compileOnly deps.compose.tooling + + implementation deps.kotlin.stdlib + implementation deps.compose.foundation +} diff --git a/backstack/consumer-rules.pro b/backstack/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/backstack/proguard-rules.pro b/backstack/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/backstack/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/backstack/src/main/AndroidManifest.xml b/backstack/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4bb8456 --- /dev/null +++ b/backstack/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt b/backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt new file mode 100644 index 0000000..bcc4f20 --- /dev/null +++ b/backstack/src/main/java/com/zachklipp/compose/backstack/Backstack.kt @@ -0,0 +1,243 @@ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry", "FunctionName") + +package com.zachklipp.compose.backstack + +import androidx.animation.AnimationBuilder +import androidx.animation.AnimationEndReason +import androidx.animation.AnimationEndReason.TargetReached +import androidx.animation.TweenBuilder +import androidx.compose.Composable +import androidx.compose.key +import androidx.compose.remember +import androidx.compose.state +import androidx.ui.animation.animatedFloat +import androidx.ui.core.Clip +import androidx.ui.core.ContextAmbient +import androidx.ui.core.Modifier +import androidx.ui.foundation.Box +import androidx.ui.foundation.shape.RectangleShape +import androidx.ui.layout.Stack +import com.zachklipp.compose.backstack.TransitionDirection.Backward +import com.zachklipp.compose.backstack.TransitionDirection.Forward + +/** + * Identifies which direction a transition is being performed in. + */ +enum class TransitionDirection { + Forward, + Backward +} + +/** + * Wraps each screen composable with the transition modifier derived from the current animation + * progress. + */ +private data class ScreenWrapper( + val key: T, + val transition: @Composable() (progress: Float, @Composable() () -> Unit) -> Unit +) + +@Composable +private val DefaultBackstackAnimation: AnimationBuilder + get() { + val context = ContextAmbient.current + return TweenBuilder().apply { + duration = 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 + * value changes. Any state used by a screen will be preserved as long as it remains in the stack + * (i.e. result of [remember] or [state] calls). + * + * The [backstack] must follow some rules: + * - Must always contain at least one item. + * - Elements in the stack must implement `equals` and not change over the lifetime of the screen. + * If the key changes, it will be considered a new screen and any state held by the screen will + * be lost. + * - If items in the stack are reordered between compositions, the stack should not contain + * duplicates. If it does, due to how `@Pivotal` works, the states of those screens will be + * lost if they are moved around. Duplicates should retain state if they are not reordered. + * + * This composable does not actually provide any navigation functionality – it just renders + * transitions between stacks of screens. It can be plugged into your navigation library of choice, + * or just used on its own with a simple list of screens, like this: + * + * ``` + * sealed class Screen { + * object ContactList: Screen() + * data class ContactDetails(val id: String): Screen() + * data class EditContact(val id: String): Screen() + * } + * + * data class Navigator( + * val push: (Screen) -> Unit, + * val pop: () -> Unit + * ) + * + * @Composable fun App() { + * var backstack by state { listOf(Screen.ContactList) } + * val navigator = remember { + * Navigator( + * push = { backstack += it }, + * pop = { backstack = backstack.dropLast(1) } + * ) + * } + * + * Backstack(backstack) { screen -> + * when(screen) { + * Screen.ContactList -> ShowContactList(navigator) + * is Screen.ContactDetails -> ShowContact(screen.id, navigator) + * is Screen.EditContact -> ShowEditContact(screen.id, navigator) + * } + * } + * } + * ``` + * + * @param backstack The stack of screen values. + * @param modifier [Modifier] that will be applied to the container of screens. Neither affects nor + * is affected by transition animations. + * @param transition The [BackstackTransition] that defines how to animate between screens when + * [backstack] changes. [BackstackTransition] contains a few simple pre-fab transitions. + * @param animationBuilder Defines the curve and speed of transition animations. + * @param onTransitionStarting Callback that will be invoked before starting each transition. + * @param onTransitionFinished Callback that will be invoked after each transition finishes. + * @param drawScreen Called with each element of [backstack] to render it. + */ +@Composable +fun Backstack( + backstack: List, + modifier: Modifier = Modifier.None, + transition: BackstackTransition = BackstackTransition.Slide, + animationBuilder: AnimationBuilder? = null, + onTransitionStarting: ((from: List, to: List, TransitionDirection) -> Unit)? = null, + onTransitionFinished: (() -> Unit)? = null, + drawScreen: @Composable() (T) -> Unit +) { + require(backstack.isNotEmpty()) { "Backstack must contain at least 1 screen." } + + // 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 state { 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 state { 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 state { 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 state { 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 + + 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 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 + + // 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 + } + } + + // 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 -> + val isTop = index == activeKeys.size - 1 + ScreenWrapper(key) { progress, children -> + val visibility = when { + // transitionProgress always corresponds directly to visibility of the top screen. + isTop -> progress + // The second-to-top screen has the inverse visibility of the top screen. + index == activeKeys.size - 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 transitionModifier = transition.modifierForScreen(visibility, isTop) + Box(transitionModifier, children = children) + } + } + } + + // Actually draw the screens. + Stack(modifier = modifier) { + // Note: in dev07, this Clip should be replaceable with DrawClipToBounds. + 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) { + // Cache the composable that actually draws this item so it's not recomposed if the + // backstack doesn't change. This helps performance with long backstacks. + // We don't need to pass item to remember because key guarantees that it won't change + // within this part of the composition. + val drawItem: @Composable() () -> Unit = remember { + @Composable { drawScreen(item) } + } + transition(transitionProgress.value, drawItem) + } + } + } + } +} diff --git a/backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt b/backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt new file mode 100644 index 0000000..825af21 --- /dev/null +++ b/backstack/src/main/java/com/zachklipp/compose/backstack/BackstackTransition.kt @@ -0,0 +1,93 @@ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.zachklipp.compose.backstack + +import androidx.compose.Composable +import androidx.ui.core.DrawModifier +import androidx.ui.core.LayoutModifier +import androidx.ui.core.Modifier +import androidx.ui.core.ModifierScope +import androidx.ui.graphics.Canvas +import androidx.ui.graphics.Paint +import androidx.ui.graphics.withSaveLayer +import androidx.ui.unit.* +import com.zachklipp.compose.backstack.BackstackTransition.Crossfade +import com.zachklipp.compose.backstack.BackstackTransition.Slide + +/** + * Defines transitions for a [Backstack]. Transitions control how screens are rendered by returning + * [Modifier]s that will be used to wrap screen composables. + * + * @see Slide + * @see Crossfade + */ +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. + */ + @Composable + fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier + + /** + * A simple transition that slides screens horizontally. + */ + object Slide : BackstackTransition { + @Composable + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier = PercentageLayoutOffset( + offset = if (isTop) 1f - visibility else -1 + visibility + ) + + // Note: In dev07, use LayoutOffset modifier instead. + private class PercentageLayoutOffset(private val offset: Float) : LayoutModifier { + override fun ModifierScope.modifyPosition( + childSize: IntPxSize, + containerSize: IntPxSize + ): IntPxPosition { + val realOffset = offset.coerceIn(-1f..1f) + return IntPxPosition( + x = containerSize.width * realOffset, + y = 0.ipx + ) + } + } + } + + /** + * A simple transition that crossfades between screens. + */ + object Crossfade : BackstackTransition { + @Composable + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier = OpacityModifier(visibility) + + // Note: In dev07 this modifier is built-in as drawOpacity. + private class OpacityModifier(opacity: Float) : DrawModifier { + private val paint = Paint().also { it.alpha = opacity } + + override fun draw( + density: Density, + drawContent: () -> Unit, + canvas: Canvas, + size: PxSize + ) { + canvas.withSaveLayer(size.toRect(), paint, drawContent) + } + } + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..0673c10 --- /dev/null +++ b/build.gradle @@ -0,0 +1,102 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.versions = [ + targetSdk: 29, + compose: '0.1.0-dev06', + kotlin: '1.3.70', + ] + + rootProject.ext.defaultAndroidConfig = { + compileSdkVersion versions.targetSdk + buildToolsVersion '29.0.2' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 21 + targetSdkVersion versions.targetSdk + versionCode 1 + versionName "1.0" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion versions.compose + } + } + + ext.deps = [ + android_gradle_plugin: "com.android.tools.build:gradle:4.1.0-alpha02", + + androidx: [ + activity: "androidx.activity:activity:1.1.0", + annotations: "androidx.annotation:annotation:1.1.0", + appcompat: "androidx.appcompat:appcompat:1.1.0", + constraint_layout: "androidx.constraintlayout:constraintlayout:1.1.3", + fragment: "androidx.fragment:fragment:1.2.2", + // Note that we're not using the actual androidx material dep yet, it's still alpha. + material: "com.google.android.material:material:1.1.0", + 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. + savedstate: "androidx.savedstate:savedstate:1.0.0", + transition: "androidx.transition:transition:1.3.1", + viewbinding: "androidx.databinding:viewbinding:3.6.1", + ], + compose: [ + foundation: "androidx.ui:ui-foundation:${versions.compose}", + icons: "androidx.ui:ui-material-icons-extended:${versions.compose}", + layout: "androidx.ui:ui-layout:${versions.compose}", + material: "androidx.ui:ui-material:${versions.compose}", + test: "androidx.ui:ui-test:${versions.compose}", + tooling: "androidx.ui:ui-tooling:${versions.compose}", + ], + kotlin: [ + binaryCompatibilityValidatorPlugin: "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.1", + gradlePlugin: "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}", + stdlib: "org.jetbrains.kotlin:kotlin-stdlib-jdk8", + reflect: "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin}", + test: [ + common: "org.jetbrains.kotlin:kotlin-test-common", + annotations: "org.jetbrains.kotlin:kotlin-test-annotations-common", + jdk: "org.jetbrains.kotlin:kotlin-test-junit", + mockito: "com.nhaarman:mockito-kotlin-kt1.1:1.6.0" + ] + ], + mavenPublish: "com.vanniktech:gradle-maven-publish-plugin:0.9.0", + ktlint: "org.jlleitschuh.gradle:ktlint-gradle:9.2.0", + ] + + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + + dependencies { + classpath deps.android_gradle_plugin + classpath deps.kotlin.gradlePlugin + classpath deps.ktlint + } +} + +subprojects { + repositories { + google() + mavenCentral() + jcenter() + } + + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "1.8" + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4d15d01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f3d88b1 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..84a9066 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.2.2-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24467a1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sample/build.gradle b/sample/build.gradle new file mode 100644 index 0000000..891ba24 --- /dev/null +++ b/sample/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +android rootProject.ext.defaultAndroidConfig + +android { + defaultConfig { + applicationId "com.zachklipp.compose.backstack" + } +} + +dependencies { + implementation project(':backstack') + implementation deps.androidx.appcompat + implementation deps.compose.icons + implementation deps.compose.foundation + implementation deps.compose.material + implementation deps.compose.tooling + implementation deps.kotlin.stdlib +} diff --git a/sample/proguard-rules.pro b/sample/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/sample/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a89f077 --- /dev/null +++ b/sample/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/sample/src/main/java/com/zachklipp/compose/backstack/sample/App.kt b/sample/src/main/java/com/zachklipp/compose/backstack/sample/App.kt new file mode 100644 index 0000000..85a697b --- /dev/null +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/App.kt @@ -0,0 +1,166 @@ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry", "FunctionName") + +package com.zachklipp.compose.backstack.sample + +import androidx.animation.TweenBuilder +import androidx.compose.Composable +import androidx.compose.remember +import androidx.compose.state +import androidx.ui.core.DrawModifier +import androidx.ui.core.Modifier +import androidx.ui.core.Text +import androidx.ui.foundation.DrawBorder +import androidx.ui.graphics.Canvas +import androidx.ui.graphics.Color +import androidx.ui.graphics.withSave +import androidx.ui.layout.* +import androidx.ui.material.* +import androidx.ui.material.surface.Surface +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.Density +import androidx.ui.unit.PxSize +import androidx.ui.unit.dp +import com.zachklipp.compose.backstack.Backstack +import com.zachklipp.compose.backstack.BackstackTransition +import com.zachklipp.compose.backstack.BackstackTransition.Crossfade +import com.zachklipp.compose.backstack.BackstackTransition.Slide +import kotlin.math.pow + +private val backstacks = listOf( + listOf("one"), + listOf("one", "two"), + listOf("one", "two", "three"), + listOf("two", "one") +).map { it.joinToString() to it } + +private val backstackTransitions = listOf( + "Slide" to Slide, + "Crossfade" to Crossfade, + "Fancy" to FancyTransition +) + +//@Composable +//fun App() { +// Center { +// Text("Hi") +// } +//} + +@Composable +fun App() { + MaterialTheme(colors = darkColorPalette()) { + Surface { + var selectedTransition by state { backstackTransitions.first() } + var selectedBackstack by state { backstacks.first() } + var slowAnimations by state { false } + val animation = remember(slowAnimations) { + if (slowAnimations) TweenBuilder().apply { duration = 2000 } else null + } + + Column(modifier = LayoutSize.Fill) { + Text("Backstack transition:") + Spinner( + items = backstackTransitions, + selectedItem = selectedTransition, + onSelected = { selectedTransition = it } + ) { + ListItem(text = it.first) + } + + Row { + Text("Slow animations:", modifier = LayoutGravity.Center) + Switch(slowAnimations, onCheckedChange = { slowAnimations = it }) + } + + Text("Backstack:") + RadioGroup() { + backstacks.forEach { backstack -> + RadioGroupTextItem( + text = backstack.first, + textStyle = MaterialTheme.typography().body1, + selected = backstack == selectedBackstack, + onSelect = { selectedBackstack = backstack } + ) + } + } + + MaterialTheme(colors = lightColorPalette()) { + Backstack( + backstack = selectedBackstack.second, + transition = selectedTransition.second, + animationBuilder = animation, + modifier = LayoutSize.Fill + + LayoutPadding(24.dp) + + DrawBorder(size = 3.dp, color = Color.Red), + onTransitionStarting = { from, to, direction -> + println( + """ + Transitioning $direction: + from: $from + to: $to + """.trimIndent() + ) + }, + onTransitionFinished = { println("Transition finished.") } + ) { screen -> + AppScreen( + name = screen, + isLastScreen = screen == selectedBackstack.second.first(), + onAdd = { + val newBackstack = selectedBackstack.second + "$screen+" + selectedBackstack = newBackstack.joinToString() to newBackstack + }, + onBack = { + if (selectedBackstack.second.size > 1) { + val newBackstack = selectedBackstack.second.dropLast(1) + selectedBackstack = newBackstack.joinToString() to newBackstack + } + } + ) + } + } + } + } + } +} + +@Preview +@Composable +fun AppPreview() { + App() +} + +private object FancyTransition : BackstackTransition { + @Composable + override fun modifierForScreen( + visibility: Float, + isTop: Boolean + ): Modifier { + return if (isTop) { + Slide.modifierForScreen(visibility.pow(1.1f), isTop) + + Crossfade.modifierForScreen(visibility.pow(.1f), isTop) + } else { + ScaleModifier(visibility.pow(.1f)) + + Crossfade.modifierForScreen(visibility.pow(.5f), isTop) + } + } + + private class ScaleModifier(private val factor: Float) : DrawModifier { + override fun draw( + density: Density, + drawContent: () -> Unit, + canvas: Canvas, + size: PxSize + ) { + val halfWidth = size.width.value / 2 + val halfHeight = size.height.value / 2 + + canvas.withSave { + canvas.translate(halfWidth, halfHeight) + canvas.scale(factor) + canvas.translate(-halfWidth, -halfHeight) + drawContent() + } + } + } +} diff --git a/sample/src/main/java/com/zachklipp/compose/backstack/sample/AppScreen.kt b/sample/src/main/java/com/zachklipp/compose/backstack/sample/AppScreen.kt new file mode 100644 index 0000000..7965a31 --- /dev/null +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/AppScreen.kt @@ -0,0 +1,67 @@ +@file:Suppress("FunctionName") + +package com.zachklipp.compose.backstack.sample + +import android.os.Handler +import androidx.compose.Composable +import androidx.compose.Pivotal +import androidx.compose.onActive +import androidx.compose.state +import androidx.ui.core.Text +import androidx.ui.foundation.Icon +import androidx.ui.layout.Center +import androidx.ui.material.FloatingActionButton +import androidx.ui.material.IconButton +import androidx.ui.material.Scaffold +import androidx.ui.material.TopAppBar +import androidx.ui.material.icons.Icons +import androidx.ui.material.icons.filled.Add +import androidx.ui.material.icons.filled.ArrowBack +import androidx.ui.material.icons.filled.Backup +import androidx.ui.material.icons.filled.Menu +import androidx.ui.tooling.preview.Preview + +@Preview +@Composable fun AppScreenPreview() { + AppScreen(name = "preview", isLastScreen = false, onBack = {}, onAdd = {}) +} + +@Composable fun AppScreen( + name: String, + isLastScreen: Boolean, + onBack: () -> Unit, + onAdd: () -> Unit +) { + Scaffold( + topAppBar = { + val navigationIcon = if (isLastScreen) Icons.Default.Menu else Icons.Default.ArrowBack + TopAppBar( + navigationIcon = { IconButton(onBack) { Icon(navigationIcon) } }, + title = { Text(name) }) + }, + floatingActionButton = { FloatingActionButton(onClick = onAdd) { Icon(Icons.Default.Add) } } + ) { + Center { + Text(text = "Counter: ${Counter(200)}") + } + } +} + +@Suppress("SameParameterValue") +@Composable +private fun Counter(@Pivotal periodMs: Long): Int { + var value by state { 0 } + onActive { + val mainHandler = Handler() + var disposed = false + onDispose { disposed = true } + fun schedule() { + mainHandler.postDelayed({ + value++ + if (!disposed) schedule() + }, periodMs) + } + schedule() + } + return value +} 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 new file mode 100644 index 0000000..90b4abb --- /dev/null +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/ComposeBackstackActivity.kt @@ -0,0 +1,14 @@ +package com.zachklipp.compose.backstack.sample + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.setViewContent +import androidx.ui.core.setContent +import com.zachklipp.compose.backstack.sample.App + +class ComposeBackstackActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { App() } + } +} diff --git a/sample/src/main/java/com/zachklipp/compose/backstack/sample/Spinner.kt b/sample/src/main/java/com/zachklipp/compose/backstack/sample/Spinner.kt new file mode 100644 index 0000000..85a5b55 --- /dev/null +++ b/sample/src/main/java/com/zachklipp/compose/backstack/sample/Spinner.kt @@ -0,0 +1,72 @@ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.zachklipp.compose.backstack.sample + +import androidx.compose.Composable +import androidx.compose.state +import androidx.ui.foundation.Box +import androidx.ui.foundation.Clickable +import androidx.ui.foundation.Dialog +import androidx.ui.graphics.vector.DrawVector +import androidx.ui.layout.Column +import androidx.ui.layout.Container +import androidx.ui.layout.LayoutAspectRatio +import androidx.ui.layout.LayoutGravity +import androidx.ui.layout.LayoutWidth +import androidx.ui.layout.Row +import androidx.ui.material.icons.Icons +import androidx.ui.material.icons.filled.ArrowDropDown +import androidx.ui.material.ripple.Ripple +import androidx.ui.material.surface.Surface +import androidx.ui.unit.dp + +/** + * Rough implementation of the Android Spinner widget. + */ +@Composable fun Spinner( + items: List, + selectedItem: T, + onSelected: (item: T) -> Unit, + drawItem: @Composable() (T) -> Unit +) { + if (items.isEmpty()) return + + var isOpen by state { false } + + // Always draw the selected item. + Container { + Ripple(bounded = true) { + Clickable(onClick = { isOpen = !isOpen }) { + Row { + Box(modifier = LayoutFlexible(1f)) { + drawItem(selectedItem) + } + Box(modifier = LayoutWidth(48.dp) + LayoutAspectRatio(1f) + LayoutGravity.Center) { + DrawVector(vectorImage = Icons.Default.ArrowDropDown) + } + } + } + } + + if (isOpen) { + // TODO use DropdownPopup. + Dialog(onCloseRequest = { isOpen = false }) { + Surface(elevation = 1.dp) { + Column { + for (item in items) { + Ripple(bounded = true) { + Clickable( + onClick = { + isOpen = false + if (item != selectedItem) onSelected(item) + }) { + drawItem(item) + } + } + } + } + } + } + } + } +} diff --git a/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..5968f74 --- /dev/null +++ b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..bf1c8bc --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..03eed25 --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..03eed25 --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/sample/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a571e60 Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..61da551 Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.png b/sample/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c41dd28 Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db5080a Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6dba46d Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da31a87 Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..15ac681 Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b216f2d Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f25a419 Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e96783c Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml new file mode 100644 index 0000000..9b4f6b5 --- /dev/null +++ b/sample/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #6200EE + #3700B3 + #03DAC5 + \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..bf7f69a --- /dev/null +++ b/sample/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Compose Backstack Sample + diff --git a/sample/src/main/res/values/styles.xml b/sample/src/main/res/values/styles.xml new file mode 100644 index 0000000..ea4f08f --- /dev/null +++ b/sample/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..43b827f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,3 @@ +include ':backstack' +include ':sample' +rootProject.name = "compose-backstack" \ No newline at end of file