diff --git a/build-logic/convention/src/main/kotlin/android-app-library-convention.kt b/build-logic/convention/src/main/kotlin/android-app-library-convention.kt index a124a5a..512f675 100644 --- a/build-logic/convention/src/main/kotlin/android-app-library-convention.kt +++ b/build-logic/convention/src/main/kotlin/android-app-library-convention.kt @@ -41,16 +41,13 @@ import org.gradle.api.artifacts.VersionCatalogsExtension fun org.gradle.api.Project.androidConfiguration( extension: CommonExtension<*, *, *, *, *, *> ) = extension.apply { - namespace = "com.tunjid.composables.${project.name}" - compileSdk = 35 + namespace = "com.tunjid.treenav.${project.name.replace("-", ".")}" + compileSdk = 36 defaultConfig { minSdk = 23 } - buildFeatures { - compose = true - } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt b/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt index 8e6d183..cd710a8 100644 --- a/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt +++ b/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt @@ -62,26 +62,13 @@ private fun org.gradle.api.Project.configureKotlin() { jvmTarget.set(JvmTarget.JVM_11) freeCompilerArgs.set( freeCompilerArgs.get() + listOf( - "-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi", - "-Xuse-experimental=androidx.compose.material.ExperimentalMaterialApi", - "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi", - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview" + "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", + "-opt-in=androidx.compose.material.ExperimentalMaterialApi", + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview" ) ) } } -// val kotlinOptions = "kotlinOptions" -// if (extensions.findByName(kotlinOptions) != null) { -// extensions.configure(kotlinOptions, Action { -// jvmTarget = JavaVersion.VERSION_11.toString() -// freeCompilerArgs = freeCompilerArgs + listOf( -// "-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi", -// "-Xuse-experimental=androidx.compose.material.ExperimentalMaterialApi", -// "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi", -// "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", -// "-Xuse-experimental=kotlinx.coroutines.FlowPreview" -// ) -// }) -// } } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts b/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts index 608cc29..d4f69b1 100644 --- a/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts +++ b/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts @@ -38,13 +38,25 @@ plugins { } kotlin { - androidTarget() - jvm("desktop") -// js(IR) { -// browser() -// nodejs() -// binaries.executable() -// } + applyDefaultHierarchyTemplate() + androidTarget { + publishLibraryVariants("release") + } + jvm { + testRuns["test"].executionTask.configure { + useJUnit() + } + } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = project.name + isStatic = true + } + } sourceSets { all { languageSettings.apply { @@ -58,28 +70,6 @@ kotlin { } } } - - targets.withType(KotlinNativeTarget::class.java) { - binaries.all { - binaryOptions["memoryModel"] = "experimental" - } - } configureKotlinJvm() } -// a temporary workaround for a bug in jsRun invocation - see https://youtrack.jetbrains.com/issue/KT-48273 -afterEvaluate { - rootProject.extensions.configure { - versions.webpackDevServer.version = "4.0.0" - versions.webpackCli.version = "4.10.0" - nodeVersion = "16.0.0" - } -} - - -// TODO: remove when https://youtrack.jetbrains.com/issue/KT-50778 fixed -project.tasks.withType(org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile::class.java).configureEach { - kotlinOptions.freeCompilerArgs += listOf( - "-Xir-dce-runtime-diagnostic=log" - ) -} diff --git a/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts b/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts index 60fc96c..0be34be 100644 --- a/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts +++ b/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts @@ -57,12 +57,12 @@ publishing { artifact(javadocJar) pom { name.set(project.name) - description.set("A collection of utility composable functions") - url.set("https://github.com/tunjid/composables") + description.set("A kotlin multiplatform experiment for representing app navigation with tree like data structures") + url.set("https://github.com/tunjid/treenav") licenses { license { name.set("Apache License 2.0") - url.set("https://github.com/tunjid/composables/blob/main/LICENSE") + url.set("https://github.com/tunjid/treenav/blob/main/LICENSE.txt") } } developers { @@ -73,9 +73,9 @@ publishing { } } scm { - connection.set("scm:git:github.com/tunjid/composables.git") - developerConnection.set("scm:git:ssh://github.com/tunjid/composables.git") - url.set("https://github.com/tunjid/composables/tree/main") + connection.set("scm:git:github.com/tunjid/treenav.git") + developerConnection.set("scm:git:ssh://github.com/tunjid/treenav.git") + url.set("https://github.com/tunjid/treenav/tree/main") } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b7ca99..138c8e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] androidGradlePlugin = "8.9.2" androidxActivity = "1.9.2" -activity-compose = "1.10.1" +activity-compose = "1.11.0-rc01" androidxAppCompat = "1.7.0" androidxBenchmark = "1.3.4" androidxCore = "1.16.0" @@ -39,26 +39,6 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } -androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } -androidx-compose-foundation-foundation = { group = "androidx.compose.foundation", name = "foundation" } -androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } -androidx-compose-material-core = { group = "androidx.compose.material", name = "material" } -androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } -androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } -androidx-compose-ui-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-compose-ui-platform = { group = "androidx.compose.ui", name = "ui-platform" } -androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-compose-material3-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" } -androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" } -androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" } -androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } -androidx-savedstate-savedstate = { group = "androidx.compose.savedstate", name = "savedstate", version.ref = "androidxSavedState" } -androidx-savedstate-compose = { group = "androidx.compose.savedstate", name = "savedstate-compose", version.ref = "androidxSavedState" } jetbrains-compose-animation = { group = "org.jetbrains.compose.animation", name = "animation", version.ref = "jetbrainsCompose" } jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "jetbrainsCompose" } diff --git a/library/compose-threepane/.gitignore b/library/compose-threepane/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/library/compose-threepane/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/library/compose-threepane/build.gradle.kts b/library/compose-threepane/build.gradle.kts new file mode 100644 index 0000000..6645d65 --- /dev/null +++ b/library/compose-threepane/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + kotlin("multiplatform") + id("publishing-library-convention") + id("android-library-convention") + id("kotlin-jvm-convention") + id("kotlin-library-convention") + id("maven-publish") + signing + id("org.jetbrains.dokka") + id("org.jetbrains.compose") + alias(libs.plugins.compose.compiler) +} + +android { + buildFeatures { + compose = true + } +} + +kotlin { + sourceSets { + commonMain { + dependencies { + implementation(project(":library:treenav")) + implementation(project(":library:compose")) + + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.foundation.layout) + } + } + } +} + diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/PaneMovableElementSharedTransitionScope.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/PaneMovableElementSharedTransitionScope.kt new file mode 100644 index 0000000..88d899c --- /dev/null +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/PaneMovableElementSharedTransitionScope.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2021 Google LLC + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.threepane + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.PaneSharedTransitionScope +import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope +import com.tunjid.treenav.compose.threepane.transforms.requireMovableSharedElementScope + +/** + * An interface providing both [MovableSharedElementScope] and [PaneSharedTransitionScope] for + * a [ThreePane] layout. + */ +@Stable +interface PaneMovableElementSharedTransitionScope : + PaneSharedTransitionScope, MovableSharedElementScope + +/** + * Remembers a [PaneMovableElementSharedTransitionScope] in the composition. + * + * @param movableSharedElementScope The [MovableSharedElementScope] used create a + * [PaneSharedTransitionScope] for this [PaneScope]. + * + * If one is not provided, one is retrieved from this [PaneScope] using + * [requireMovableSharedElementScope]. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PaneScope< + ThreePane, + Destination + >.rememberPaneMovableElementSharedTransitionScope( + movableSharedElementScope: MovableSharedElementScope = requireMovableSharedElementScope() +): PaneMovableElementSharedTransitionScope { + val paneSharedTransitionScope = rememberPaneSharedTransitionScope( + movableSharedElementScope.sharedTransitionScope + ) + return remember { + DelegatingPaneMovableElementSharedTransitionScope( + paneSharedTransitionScope = paneSharedTransitionScope, + movableSharedElementScope = movableSharedElementScope, + ) + } +} + +@Stable +private class DelegatingPaneMovableElementSharedTransitionScope( + val paneSharedTransitionScope: PaneSharedTransitionScope, + val movableSharedElementScope: MovableSharedElementScope, +) : PaneMovableElementSharedTransitionScope, + PaneSharedTransitionScope by paneSharedTransitionScope, + MovableSharedElementScope by movableSharedElementScope + diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt similarity index 100% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt rename to library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt new file mode 100644 index 0000000..bf3bc52 --- /dev/null +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2021 Google LLC + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.threepane + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SharedTransitionScope.OverlayClip +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.PaneSharedTransitionScope + +/** + * Creates and remembers a [PaneSharedTransitionScope] for [ThreePane] layouts with + * opinionated semantics for how shared elements move between panes, especially with back previews. + * + * @param sharedTransitionScope the [SharedTransitionScope] to be delegated to for core + * shared transition APIs. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PaneScope< + ThreePane, + Destination + >.rememberPaneSharedTransitionScope( + sharedTransitionScope: SharedTransitionScope, +): PaneSharedTransitionScope = + remember { + ThreePaneSharedTransitionScope( + paneScope = this, + sharedTransitionScope = sharedTransitionScope + ) + } + +@OptIn(ExperimentalSharedTransitionApi::class) +@Stable +private class ThreePaneSharedTransitionScope @OptIn( + ExperimentalSharedTransitionApi::class +) constructor( + val paneScope: PaneScope, + val sharedTransitionScope: SharedTransitionScope, +) : PaneSharedTransitionScope, + PaneScope by paneScope, + SharedTransitionScope by sharedTransitionScope { + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun Modifier.paneSharedElement( + key: Any, + boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + visible: Boolean?, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + ): Modifier = composed { + + when (paneScope.paneState.pane) { + null -> throw IllegalArgumentException( + "Shared elements may only be used in non null panes" + ) + // Allow shared elements in the primary or transient primary content only + ThreePane.Primary -> when { + paneScope.isPreviewingBack -> sharedElementWithCallerManagedVisibility( + sharedContentState = rememberSharedContentState(key), + visible = false, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + // Share the element + else -> sharedElementWithCallerManagedVisibility( + sharedContentState = rememberSharedContentState(key), + visible = when (visible) { + null -> paneScope.isActive + else -> paneScope.isActive && visible + }, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + } + // Share the element when in the transient pane + ThreePane.TransientPrimary -> sharedElementWithCallerManagedVisibility( + sharedContentState = rememberSharedContentState(key), + visible = paneScope.isActive, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + + // In the other panes use the element as is + ThreePane.Secondary, + ThreePane.Tertiary, + ThreePane.Overlay, + -> this + } + } +} + +private val PaneScope.isPreviewingBack: Boolean + get() = paneState.pane == ThreePane.Primary + && paneState.adaptations.contains(ThreePane.PrimaryToTransient) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt similarity index 100% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt rename to library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt similarity index 79% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt rename to library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index 6345087..906ab36 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2021 Google LLC + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.tunjid.treenav.compose.threepane.transforms import androidx.compose.animation.BoundsTransform @@ -17,7 +33,7 @@ import com.tunjid.treenav.compose.transforms.RenderTransform import com.tunjid.treenav.compose.transforms.Transform import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope -import com.tunjid.treenav.compose.moveablesharedelement.PanedMovableSharedElementScope +import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane /** @@ -33,7 +49,7 @@ fun ): Transform = RenderTransform { destination, previousTransform -> val delegate = remember { - PanedMovableSharedElementScope( + PaneMovableSharedElementScope( paneScope = this, movableSharedElementHostState = movableSharedElementHostState, ) @@ -50,16 +66,20 @@ fun previousTransform(movableSharedElementScope, destination) } +/** + * Requires that this [PaneScope] is a [MovableSharedElementScope], and returns it. In the + * case this [PaneScope] is not a [MovableSharedElementScope], an exception will be thrown. + */ +@Stable fun PaneScope< ThreePane, Destination - >.requireThreePaneMovableSharedElementScope(): MovableSharedElementScope { + >.requireMovableSharedElementScope(): MovableSharedElementScope { check(this is ThreePaneMovableSharedElementScope) { """ - The current AdaptivePaneScope (${this::class.qualifiedName}) is not an instance of - a ThreePaneMovableSharedElementScope. You must configure your ThreePane AdaptiveNavHost with - AdaptiveNavHostConfiguration.movableSharedElementConfiguration(movableSharedElementHostState). - + The current PaneScope (${this::class.qualifiedName}) is not an instance of + a ThreePaneMovableSharedElementScope. You must configure your ThreePane MultiPaneDisplay with + threePanedMovableSharedElementTransform(). """.trimIndent() } return this @@ -69,9 +89,13 @@ fun PaneScope< @Stable private class ThreePaneMovableSharedElementScope( private val hostState: MovableSharedElementHostState, - private val delegate: PanedMovableSharedElementScope, -) : MovableSharedElementScope, SharedTransitionScope by delegate, + private val delegate: PaneMovableSharedElementScope, +) : MovableSharedElementScope, PaneScope by delegate.paneScope { + + override val sharedTransitionScope: SharedTransitionScope + get() = delegate.sharedTransitionScope + @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( key: Any, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt similarity index 78% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt rename to library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt index b9293ba..3656215 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2021 Google LLC + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.tunjid.treenav.compose.threepane.transforms import androidx.compose.runtime.State diff --git a/library/compose/build.gradle.kts b/library/compose/build.gradle.kts index b42c0a9..c7c7f15 100644 --- a/library/compose/build.gradle.kts +++ b/library/compose/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("publishing-library-convention") id("android-library-convention") id("kotlin-jvm-convention") + id("kotlin-library-convention") id("maven-publish") signing id("org.jetbrains.dokka") @@ -10,25 +11,13 @@ plugins { alias(libs.plugins.compose.compiler) } -kotlin { - applyDefaultHierarchyTemplate() - androidTarget() - jvm { - testRuns["test"].executionTask.configure { - useJUnit() - } - } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "treenav-compose" - isStatic = true - } +android { + buildFeatures { + compose = true } +} +kotlin { sourceSets { commonMain { dependencies { @@ -62,8 +51,6 @@ kotlin { implementation(kotlin("test")) } } - val jvmMain by getting - val jvmTest by getting all { languageSettings.apply { diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Defaults.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Defaults.kt new file mode 100644 index 0000000..2f8c5f2 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/Defaults.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2021 Google LLC + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope.OverlayClip +import androidx.compose.animation.SharedTransitionScope.SharedContentState +import androidx.compose.animation.core.Spring.StiffnessMediumLow +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +@Stable +internal object Defaults { + + val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, _ -> } + + @ExperimentalSharedTransitionApi + val DefaultBoundsTransform = BoundsTransform { _, _ -> DefaultSpring } + + @ExperimentalSharedTransitionApi + val ParentClip: OverlayClip = + object : OverlayClip { + override fun getClipPath( + sharedContentState: SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density, + ): Path? { + return sharedContentState.parentSharedContentState?.clipPathInOverlay + } + } + +} + +private val DefaultSpring = spring( + stiffness = StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold +) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt new file mode 100644 index 0000000..d85256e --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Google LLC + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SharedTransitionScope.OverlayClip +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.contentSize +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.tunjid.treenav.Node + +/** + * A [SharedTransitionScope] that is aware of the relationship between the [Pane]s in + * its [MultiPaneDisplay]. This allows for defining the semantics of + * shared element behavior when shared elements move in between [Pane]s during the + * transition. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Stable +interface PaneSharedTransitionScope : + PaneScope, SharedTransitionScope { + + /** + * Conceptual equivalent of [SharedTransitionScope.sharedElement], with the exception + * of a key being passed instead of [SharedTransitionScope.SharedContentState]. This is because + * each [PaneState.pane] may need its own [SharedTransitionScope.SharedContentState] and + * will need to be managed by the implementation of this method. + * + * @see [SharedTransitionScope.sharedElement]. + */ + fun Modifier.paneSharedElement( + key: Any, + boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, + placeHolderSize: PlaceHolderSize = contentSize, + renderInOverlayDuringTransition: Boolean = true, + visible: Boolean? = null, + zIndexInOverlay: Float = 0f, + clipInOverlayDuringTransition: OverlayClip = Defaults.ParentClip, + ): Modifier +} diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt index acec47b..2c3ce1b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt @@ -7,9 +7,6 @@ import androidx.compose.animation.SharedTransitionScope.OverlayClip import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.contentSize import androidx.compose.animation.SharedTransitionScope.SharedContentState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -18,11 +15,8 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.LayoutDirection import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Defaults import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.PaneScope @@ -32,7 +26,12 @@ import com.tunjid.treenav.compose.PaneScope */ @OptIn(ExperimentalSharedTransitionApi::class) @Stable -interface MovableSharedElementScope : SharedTransitionScope { +interface MovableSharedElementScope { + + /** + * The backing [SharedTransitionScope] for movable shared elements. + */ + val sharedTransitionScope: SharedTransitionScope /** * Creates a movable shared element that accepts a single argument [T] and a [Modifier]. @@ -109,11 +108,11 @@ fun MovableSharedElementScope.updatedMovableSharedElementOf( key: Any, state: T, modifier: Modifier = Modifier, - boundsTransform: BoundsTransform = DefaultBoundsTransform, + boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, placeHolderSize: PlaceHolderSize = contentSize, renderInOverlayDuringTransition: Boolean = true, zIndexInOverlay: Float = 0f, - clipInOverlayDuringTransition: OverlayClip = ParentClip, + clipInOverlayDuringTransition: OverlayClip = Defaults.ParentClip, alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)? = null, sharedElement: @Composable (T, Modifier) -> Unit ) = movableSharedElementOf( @@ -136,7 +135,7 @@ fun MovableSharedElementScope.updatedMovableSharedElementOf( @OptIn(ExperimentalSharedTransitionApi::class) @Stable class MovableSharedElementHostState( - private val sharedTransitionScope: SharedTransitionScope, + val sharedTransitionScope: SharedTransitionScope, ) : SharedTransitionScope by sharedTransitionScope { private val keysToMovableSharedElements = @@ -189,10 +188,13 @@ class MovableSharedElementHostState( */ @OptIn(ExperimentalSharedTransitionApi::class) @Stable -class PanedMovableSharedElementScope( +class PaneMovableSharedElementScope( paneScope: PaneScope, private val movableSharedElementHostState: MovableSharedElementHostState, -) : MovableSharedElementScope, SharedTransitionScope by movableSharedElementHostState { +) : MovableSharedElementScope { + + override val sharedTransitionScope: SharedTransitionScope + get() = movableSharedElementHostState var paneScope by mutableStateOf(paneScope) @@ -235,7 +237,7 @@ class PanedMovableSharedElementScope( // The element is being shared in its new destination, stop showing it // in the in active one movableSharedElementHostState.isCurrentlyShared(key) - && movableSharedElementHostState.isMatchFound(key) -> EmptyElement( + && movableSharedElementHostState.isMatchFound(key) -> Defaults.EmptyElement( state, Modifier.matchParentSize() ) @@ -251,27 +253,3 @@ class PanedMovableSharedElementScope( } } } - -private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, _ -> } - -@ExperimentalSharedTransitionApi -private val ParentClip: OverlayClip = - object : OverlayClip { - override fun getClipPath( - state: SharedContentState, - bounds: Rect, - layoutDirection: LayoutDirection, - density: Density - ): Path? { - return state.parentSharedContentState?.clipPathInOverlay - } - } - -@OptIn(ExperimentalSharedTransitionApi::class) -private val DefaultBoundsTransform = BoundsTransform { _, _ -> - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = Rect.VisibilityThreshold - ) -} \ No newline at end of file diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt index f59d720..2589d39 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/SlotBasedAdaptiveNavigationStateTest.kt @@ -19,7 +19,6 @@ package com.tunjid.treenav.compose import com.tunjid.treenav.Node import com.tunjid.treenav.StackNav import com.tunjid.treenav.backStack -import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.current import com.tunjid.treenav.pop import com.tunjid.treenav.push @@ -52,16 +51,22 @@ data class TestNode( override val id: String get() = name } +enum class TestPane { + One, + Two, + Three; +} + class SlotBasedAdaptiveNavigationStateTest { - private lateinit var subject: SlotBasedPanedNavigationState - private lateinit var panes: List + private lateinit var subject: SlotBasedPanedNavigationState + private lateinit var panes: List private lateinit var slots: Set @BeforeTest fun setup() { - panes = ThreePane.entries.toList() + panes = TestPane.entries.toList() slots = List(size = panes.size, init = ::Slot).toSet() subject = SlotBasedPanedNavigationState.initial( slots = slots @@ -73,20 +78,20 @@ class SlotBasedAdaptiveNavigationStateTest { subject.testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), + TestPane.One to TestNode(name = "A"), ) ) .apply { assertEquals( - expected = destinationFor(ThreePane.Primary), + expected = destinationFor(TestPane.One), actual = TestNode(name = "A"), ) assertEquals( - expected = adaptationsIn(ThreePane.Primary), + expected = adaptationsIn(TestPane.One), actual = setOf(Adaptation.Change), ) assertEquals( - expected = slotFor(ThreePane.Primary), + expected = slotFor(TestPane.One), actual = Slot(0), ) } @@ -98,51 +103,51 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.Secondary to TestNode(name = "B"), - ThreePane.Tertiary to TestNode(name = "C"), + TestPane.One to TestNode(name = "A"), + TestPane.Two to TestNode(name = "B"), + TestPane.Three to TestNode(name = "C"), ) ) .apply { // Primary assertEquals( - expected = destinationFor(ThreePane.Primary), + expected = destinationFor(TestPane.One), actual = TestNode(name = "A") ) assertEquals( - expected = adaptationsIn(ThreePane.Primary), + expected = adaptationsIn(TestPane.One), actual = setOf(Adaptation.Change) ) assertEquals( - expected = slotFor(ThreePane.Primary), + expected = slotFor(TestPane.One), actual = Slot(0) ) // Secondary assertEquals( - expected = destinationFor(ThreePane.Secondary), + expected = destinationFor(TestPane.Two), actual = TestNode(name = "B") ) assertEquals( - expected = adaptationsIn(ThreePane.Secondary), + expected = adaptationsIn(TestPane.Two), actual = setOf(Adaptation.Change) ) assertEquals( - expected = slotFor(ThreePane.Secondary), + expected = slotFor(TestPane.Two), actual = Slot(1) ) // Tertiary assertEquals( - expected = destinationFor(ThreePane.Tertiary), + expected = destinationFor(TestPane.Three), actual = TestNode(name = "C") ) assertEquals( - expected = adaptationsIn(ThreePane.Tertiary), + expected = adaptationsIn(TestPane.Three), actual = setOf(Adaptation.Change) ) assertEquals( - expected = slotFor(ThreePane.Tertiary), + expected = slotFor(TestPane.Three), actual = Slot(2) ) } @@ -154,164 +159,164 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), + TestPane.One to TestNode(name = "A"), ) ) .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), + TestPane.One to TestNode(name = "A"), ) ) .apply { assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = setOf(Adaptation.Same), - actual = adaptationsIn(ThreePane.Primary), + actual = adaptationsIn(TestPane.One), ) assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) } } @Test - fun testSameAdaptationInThreePanes() { + fun testSameAdaptationInTestPanes() { subject .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.Secondary to TestNode(name = "B"), - ThreePane.Tertiary to TestNode(name = "C"), + TestPane.One to TestNode(name = "A"), + TestPane.Two to TestNode(name = "B"), + TestPane.Three to TestNode(name = "C"), ) ) .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.Secondary to TestNode(name = "B"), - ThreePane.Tertiary to TestNode(name = "C"), + TestPane.One to TestNode(name = "A"), + TestPane.Two to TestNode(name = "B"), + TestPane.Three to TestNode(name = "C"), ) ) .apply { // Primary assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = setOf(Adaptation.Same), - actual = adaptationsIn(ThreePane.Primary), + actual = adaptationsIn(TestPane.One), ) assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) // Secondary assertEquals( expected = TestNode(name = "B"), - actual = destinationFor(ThreePane.Secondary), + actual = destinationFor(TestPane.Two), ) assertEquals( expected = setOf(Adaptation.Same), - actual = adaptationsIn(ThreePane.Secondary), + actual = adaptationsIn(TestPane.Two), ) assertEquals( expected = Slot(1), - actual = slotFor(ThreePane.Secondary), + actual = slotFor(TestPane.Two), ) // Tertiary assertEquals( expected = TestNode(name = "C"), - actual = destinationFor(ThreePane.Tertiary), + actual = destinationFor(TestPane.Three), ) assertEquals( expected = setOf(Adaptation.Same), - actual = adaptationsIn(ThreePane.Tertiary), + actual = adaptationsIn(TestPane.Three), ) assertEquals( expected = Slot(2), - actual = slotFor(ThreePane.Tertiary), + actual = slotFor(TestPane.Three), ) } } @Test - fun testChangeAdaptationInThreePanes() { + fun testChangeAdaptationInTestPanes() { subject .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.Secondary to TestNode(name = "B"), - ThreePane.Tertiary to TestNode(name = "C"), + TestPane.One to TestNode(name = "A"), + TestPane.Two to TestNode(name = "B"), + TestPane.Three to TestNode(name = "C"), ) ) .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "B"), - ThreePane.Secondary to TestNode(name = "C"), - ThreePane.Tertiary to TestNode(name = "A"), + TestPane.One to TestNode(name = "B"), + TestPane.Two to TestNode(name = "C"), + TestPane.Three to TestNode(name = "A"), ) ) .apply { // Primary assertEquals( expected = TestNode(name = "B"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.Tertiary), - Adaptation.Swap(from = ThreePane.Secondary, to = ThreePane.Primary), + Adaptation.Swap(from = TestPane.One, to = TestPane.Three), + Adaptation.Swap(from = TestPane.Two, to = TestPane.One), ), - actual = adaptationsIn(ThreePane.Primary), + actual = adaptationsIn(TestPane.One), ) assertEquals( expected = Slot(1), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) // Secondary assertEquals( expected = TestNode(name = "C"), - actual = destinationFor(ThreePane.Secondary), + actual = destinationFor(TestPane.Two), ) assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Tertiary, to = ThreePane.Secondary), - Adaptation.Swap(from = ThreePane.Secondary, to = ThreePane.Primary), + Adaptation.Swap(from = TestPane.Three, to = TestPane.Two), + Adaptation.Swap(from = TestPane.Two, to = TestPane.One), ), - actual = adaptationsIn(ThreePane.Secondary), + actual = adaptationsIn(TestPane.Two), ) assertEquals( expected = Slot(2), - actual = slotFor(ThreePane.Secondary), + actual = slotFor(TestPane.Two), ) // Tertiary assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.Tertiary), + actual = destinationFor(TestPane.Three), ) assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Tertiary, to = ThreePane.Secondary), - Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.Tertiary), + Adaptation.Swap(from = TestPane.Three, to = TestPane.Two), + Adaptation.Swap(from = TestPane.One, to = TestPane.Three), ), - actual = adaptationsIn(ThreePane.Tertiary), + actual = adaptationsIn(TestPane.Three), ) assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.Tertiary), + actual = slotFor(TestPane.Three), ) } } @@ -322,48 +327,48 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), + TestPane.One to TestNode(name = "A"), ) ) .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "B"), - ThreePane.Secondary to TestNode(name = "A"), + TestPane.One to TestNode(name = "B"), + TestPane.Two to TestNode(name = "A"), ) ) .apply { // Destination assertions assertEquals( expected = TestNode(name = "B"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.Secondary), + actual = destinationFor(TestPane.Two), ) // Adaptation assertions assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.Secondary), + Adaptation.Swap(from = TestPane.One, to = TestPane.Two), ), - actual = adaptationsIn(ThreePane.Primary), + actual = adaptationsIn(TestPane.One), ) assertEquals( - expected = setOf(ThreePane.PrimaryToSecondary), - actual = adaptationsIn(ThreePane.Secondary), + expected = setOf(Adaptation.Swap(TestPane.One, TestPane.Two)), + actual = adaptationsIn(TestPane.Two), ) // Slot assertions assertEquals( // Secondary should reuse slot 0 expected = Slot(0), - actual = slotFor(ThreePane.Secondary), + actual = slotFor(TestPane.Two), ) assertEquals( expected = Slot(1), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) } } @@ -374,63 +379,63 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), + TestPane.One to TestNode(name = "A"), ) ) .apply { assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) } .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "B"), + TestPane.One to TestNode(name = "B"), ) ) .apply { assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) } .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.TransientPrimary to TestNode(name = "B"), + TestPane.One to TestNode(name = "A"), + TestPane.Three to TestNode(name = "B"), ) ) .apply { // Destination assertions assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = TestNode(name = "B"), - actual = destinationFor(ThreePane.TransientPrimary), + actual = destinationFor(TestPane.Three), ) // Adaptation assertions assertEquals( - expected = setOf(ThreePane.PrimaryToTransient), - actual = adaptationsIn(ThreePane.Primary), + expected = setOf(Adaptation.Swap(TestPane.One, TestPane.Three)), + actual = adaptationsIn(TestPane.One), ) assertEquals( - expected = setOf(ThreePane.PrimaryToTransient), - actual = adaptationsIn(ThreePane.TransientPrimary), + expected = setOf(Adaptation.Swap(TestPane.One, TestPane.Three)), + actual = adaptationsIn(TestPane.Three), ) // Slot assertions assertEquals( expected = Slot(1), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.TransientPrimary), + actual = slotFor(TestPane.Three), ) } } @@ -441,52 +446,52 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.Secondary to TestNode(name = "B"), + TestPane.One to TestNode(name = "A"), + TestPane.Two to TestNode(name = "B"), ) ) .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "C"), - ThreePane.TransientPrimary to TestNode(name = "A"), + TestPane.One to TestNode(name = "C"), + TestPane.Three to TestNode(name = "A"), ) ) .apply { // Destination assertions assertEquals( expected = TestNode(name = "C"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.TransientPrimary), + actual = destinationFor(TestPane.Three), ) // Adaptation assertions assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.TransientPrimary), + Adaptation.Swap(from = TestPane.One, to = TestPane.Three), ), - actual = adaptationsIn(ThreePane.Primary), + actual = adaptationsIn(TestPane.One), ) assertEquals( - expected = setOf(ThreePane.PrimaryToTransient), - actual = adaptationsIn(ThreePane.TransientPrimary), + expected = setOf(Adaptation.Swap(TestPane.One, TestPane.Three)), + actual = adaptationsIn(TestPane.Three), ) // Slot assertions assertEquals( expected = Slot(1), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.TransientPrimary), + actual = slotFor(TestPane.Three), ) assertEquals( expected = null, - actual = slotFor(ThreePane.Secondary), + actual = slotFor(TestPane.Two), ) } } @@ -497,63 +502,63 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "A"), - ThreePane.Secondary to TestNode(name = "B"), + TestPane.One to TestNode(name = "A"), + TestPane.Two to TestNode(name = "B"), ) ) .testAdaptTo( navState = EmptyNavState, panesToDestinations = mapOf( - ThreePane.Primary to TestNode(name = "C"), - ThreePane.Secondary to TestNode(name = "D"), - ThreePane.TransientPrimary to TestNode(name = "A"), + TestPane.One to TestNode(name = "C"), + TestPane.Two to TestNode(name = "D"), + TestPane.Three to TestNode(name = "A"), ) ) .apply { // Destination assertions assertEquals( expected = TestNode(name = "C"), - actual = destinationFor(ThreePane.Primary), + actual = destinationFor(TestPane.One), ) assertEquals( expected = TestNode(name = "D"), - actual = destinationFor(ThreePane.Secondary), + actual = destinationFor(TestPane.Two), ) assertEquals( expected = TestNode(name = "A"), - actual = destinationFor(ThreePane.TransientPrimary), + actual = destinationFor(TestPane.Three), ) // Adaptation assertions assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.TransientPrimary) + Adaptation.Swap(from = TestPane.One, to = TestPane.Three) ), - actual = adaptationsIn(ThreePane.Primary), + actual = adaptationsIn(TestPane.One), ) assertEquals( expected = setOf(Adaptation.Change), - actual = adaptationsIn(ThreePane.Secondary), + actual = adaptationsIn(TestPane.Two), ) assertEquals( expected = setOf( - Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.TransientPrimary) + Adaptation.Swap(from = TestPane.One, to = TestPane.Three) ), - actual = adaptationsIn(ThreePane.TransientPrimary), + actual = adaptationsIn(TestPane.Three), ) // Slot assertions assertEquals( expected = Slot(1), - actual = slotFor(ThreePane.Primary), + actual = slotFor(TestPane.One), ) assertEquals( expected = Slot(2), - actual = slotFor(ThreePane.Secondary), + actual = slotFor(TestPane.Two), ) assertEquals( expected = Slot(0), - actual = slotFor(ThreePane.TransientPrimary), + actual = slotFor(TestPane.Three), ) } } @@ -570,7 +575,7 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = navStates[index], panesToDestinations = mapOf( - ThreePane.Primary to navStates[index].current(), + TestPane.One to navStates[index].current(), ) ) .apply { @@ -587,7 +592,7 @@ class SlotBasedAdaptiveNavigationStateTest { .testAdaptTo( navState = poppedNavStates[index], panesToDestinations = mapOf( - ThreePane.Primary to navStates[index].current(), + TestPane.One to navStates[index].current(), ) ) .apply { @@ -596,9 +601,9 @@ class SlotBasedAdaptiveNavigationStateTest { } } - private fun SlotBasedPanedNavigationState.testAdaptTo( + private fun SlotBasedPanedNavigationState.testAdaptTo( navState: StackNav, - panesToDestinations: Map, + panesToDestinations: Map, ) = adaptTo( slots = slots, backStackIds = navState.backStack( diff --git a/library/compose/src/main/res/values/AndroidManifest.xml b/library/compose/src/main/res/values/AndroidManifest.xml new file mode 100644 index 0000000..1ac8415 --- /dev/null +++ b/library/compose/src/main/res/values/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/library/strings/build.gradle.kts b/library/strings/build.gradle.kts index f51d4d1..1ffac0f 100644 --- a/library/strings/build.gradle.kts +++ b/library/strings/build.gradle.kts @@ -17,34 +17,19 @@ plugins { kotlin("multiplatform") id("publishing-library-convention") + id("android-library-convention") id("kotlin-jvm-convention") + id("kotlin-library-convention") id("maven-publish") signing id("org.jetbrains.dokka") } kotlin { - applyDefaultHierarchyTemplate() js(IR) { nodejs() browser() } - jvm { - withJava() - testRuns["test"].executionTask.configure { - useJUnit() - } - } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "treenav-strings" - isStatic = true - } - } linuxX64() macosX64() macosArm64() @@ -63,10 +48,5 @@ kotlin { implementation(kotlin("test")) } } - val jvmMain by getting - val jvmTest by getting - - val jsMain by getting - val jsTest by getting } } diff --git a/library/treenav/build.gradle.kts b/library/treenav/build.gradle.kts index 9d9d148..9345d69 100644 --- a/library/treenav/build.gradle.kts +++ b/library/treenav/build.gradle.kts @@ -17,34 +17,19 @@ plugins { kotlin("multiplatform") id("publishing-library-convention") + id("android-library-convention") id("kotlin-jvm-convention") + id("kotlin-library-convention") id("maven-publish") signing id("org.jetbrains.dokka") } kotlin { - applyDefaultHierarchyTemplate() js(IR) { nodejs() browser() } - jvm { - withJava() - testRuns["test"].executionTask.configure { - useJUnit() - } - } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "treenav" - isStatic = true - } - } linuxX64() macosX64() macosArm64() @@ -58,10 +43,5 @@ kotlin { implementation(kotlin("test")) } } - val jvmMain by getting - val jvmTest by getting - - val jsMain by getting - val jsTest by getting } } \ No newline at end of file diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt index 76489ed..8eca493 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -147,7 +147,7 @@ val MultiStackNav.current: Node? get() = stacks.getOrNull(currentIndex)?.childre inline fun MultiStackNav.current(): T? { val node = current ?: return null check(node is T) { - "Expected the current node to be of type ${T::class.qualifiedName} but was ${node::class.qualifiedName}." + "Expected the current node to be of type ${T::class} but was ${node::class}." } return node } \ No newline at end of file diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt index a69fc67..fd1299e 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -118,7 +118,7 @@ val StackNav.current: Node? get() = children.lastOrNull() inline fun StackNav.current(): T? { val node = current ?: return null check(node is T) { - "Expected the current node to be of type ${T::class.qualifiedName} but was ${node::class.qualifiedName}." + "Expected the current node to be of type ${T::class.simpleName} but was ${node::class.simpleName}." } return node } diff --git a/libraryVersion.properties b/libraryVersion.properties index 45b681f..a43a917 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,6 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.21 -strings_version=0.0.21 -compose_version=0.0.21 \ No newline at end of file +treenav_version=0.0.22 +strings_version=0.0.22 +compose_version=0.0.22 +compose-threepane_version=0.0.22 \ No newline at end of file diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts index 96d91db..3e450ed 100755 --- a/sample/common/build.gradle.kts +++ b/sample/common/build.gradle.kts @@ -22,22 +22,13 @@ plugins { } kotlin { - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "common" - isStatic = true - } - } sourceSets { named("commonMain") { dependencies { implementation(project(":library:treenav")) implementation(project(":library:strings")) implementation(project(":library:compose")) + implementation(project(":library:compose-threepane")) implementation(compose.components.resources) @@ -69,17 +60,7 @@ kotlin { } named("androidMain") { dependencies { - implementation(libs.androidx.compose.foundation.layout) } } - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(named("commonMain").get()) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } } } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index 9bf3e53..9a54869 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -17,13 +17,10 @@ package com.tunjid.demo.common.ui import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.animateBounds +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation @@ -42,12 +39,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.Scaffold import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -58,6 +54,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -99,20 +96,11 @@ import kotlinx.coroutines.launch @Composable fun App( appState: AppState = remember { AppState() }, -) { - NavigationSuiteScaffold( - navigationSuiteItems = { - SampleDestination.NavTabs.entries.forEach { - item( - icon = { Icon(it.icon, contentDescription = it.title) }, - label = { Text(it.title) }, - selected = it == appState.currentNavigation.current, - onClick = { appState.setTab(it) } - ) - } - } +) = Scaffold { + CompositionLocalProvider( + LocalAppState provides appState, ) { - SharedTransitionScope { sharedTransitionModifier -> + SharedTransitionLayout(Modifier.fillMaxSize()) { val backPreviewSurfaceColor = MaterialTheme.colorScheme.surfaceColorAtElevation( animateDpAsState(if (appState.isPreviewingBack) 16.dp else 0.dp).value ) @@ -122,13 +110,6 @@ fun App( sharedTransitionScope = this ) } - - var canAnimatePanes by remember { mutableStateOf(true) } - val interactingWithPanes = appState.isInteractingWithPanes() - LaunchedEffect(interactingWithPanes) { - canAnimatePanes = !interactingWithPanes - } - MultiPaneDisplay( modifier = Modifier .fillMaxSize(), @@ -136,6 +117,12 @@ fun App( remember { listOf( threePanedAdaptiveTransform( + secondaryPaneBreakPoint = mutableStateOf( + SecondaryPaneMinWidthBreakpointDp + ), + tertiaryPaneBreakPoint = mutableStateOf( + TertiaryPaneMinWidthBreakpointDp + ), windowWidthState = derivedStateOf { appState.splitLayoutState.size } @@ -150,27 +137,10 @@ fun App( movableSharedElementHostState = movableSharedElementHostState ), paneModifierTransform { - val modifier = Modifier.animateBounds( - lookaheadScope = this@SharedTransitionScope, - boundsTransform = { _, _ -> - when (paneState.pane) { - ThreePane.Primary, - ThreePane.TransientPrimary, - ThreePane.Secondary, - ThreePane.Tertiary, - -> if (canAnimatePanes) spring() else snap() - - null, - ThreePane.Overlay, - -> snap() - } - } - ) - if (paneState.pane == ThreePane.TransientPrimary) modifier + if (paneState.pane == ThreePane.TransientPrimary) Modifier .fillMaxSize() .backPreview(appState.backPreviewState) - .background(backPreviewSurfaceColor, RoundedCornerShape(16.dp)) - else modifier + else Modifier .fillMaxSize() } ) @@ -182,8 +152,7 @@ fun App( SplitLayout( state = appState.splitLayoutState, modifier = Modifier - .fillMaxSize() - then sharedTransitionModifier, + .fillMaxSize(), itemSeparators = { paneIndex, offset -> PaneSeparator( splitLayoutState = appState.splitLayoutState, @@ -297,6 +266,8 @@ class AppState( internal val isPreviewingBack get() = !backPreviewState.progress.isNaN() + internal val isMediumScreenWidthOrWider get() = splitLayoutState.size >= SecondaryPaneMinWidthBreakpointDp + internal var displayScope by mutableStateOf?>( null ) @@ -375,5 +346,11 @@ class AppState( } } +internal val LocalAppState = staticCompositionLocalOf { + TODO() +} + private val PaneSeparatorActiveWidthDp = 56.dp -private val PaneSeparatorTouchTargetWidthDp = 16.dp \ No newline at end of file +private val PaneSeparatorTouchTargetWidthDp = 16.dp +internal val SecondaryPaneMinWidthBreakpointDp = 600.dp +internal val TertiaryPaneMinWidthBreakpointDp = 1200.dp \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt new file mode 100644 index 0000000..2be659f --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Adetunji Dahunsi + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.treenav.current + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PaneScaffoldState.PaneBottomAppBar( + modifier: Modifier = Modifier, +) { + val appState = LocalAppState.current + val sharedContentState = rememberSharedContentState(BottomNavSharedElementKey) + NavigationBar( + modifier = modifier + .sharedElement( + sharedContentState = sharedContentState, + animatedVisibilityScope = this, + zIndexInOverlay = BottomNavSharedElementZIndex, + ), + ) { + SampleDestination.NavTabs.entries.forEach { item -> + NavigationBarItem( + icon = { + Icon( + imageVector = item.icon, + contentDescription = item.title, + ) + }, + selected = item == appState.currentNavigation.current, + onClick = { appState.setTab(item) } + ) + } + } +} + +@Suppress("UnusedReceiverParameter") +@Composable +fun PaneScaffoldState.PaneNavigationRail( + modifier: Modifier = Modifier, +) { + val appState = LocalAppState.current + NavigationRail( + modifier = modifier, + ) { + SampleDestination.NavTabs.entries.forEach { item -> + NavigationRailItem( + selected = item == appState.currentNavigation.current, + icon = { + Icon( + imageVector = item.icon, + contentDescription = item.title, + ) + }, + onClick = { appState.setTab(item) } + ) + } + } +} + +private data object BottomNavSharedElementKey + +private const val BottomNavSharedElementZIndex = 2f diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt new file mode 100644 index 0000000..d6db74b --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2024 Adetunji Dahunsi + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.zIndex +import com.tunjid.composables.ui.skipIf +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.threepane.PaneMovableElementSharedTransitionScope +import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.threepane.rememberPaneMovableElementSharedTransitionScope +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull +import kotlin.math.abs + +@Stable +class PaneScaffoldState internal constructor( + private val appState: AppState, + paneMovableElementSharedTransitionScope: PaneMovableElementSharedTransitionScope, +) : PaneMovableElementSharedTransitionScope by paneMovableElementSharedTransitionScope { + + internal val canShowBottomNavigation get() = !appState.isMediumScreenWidthOrWider + + internal val canShowNavRail + get() = appState.filteredPaneOrder.firstOrNull() == paneState.pane + && appState.isMediumScreenWidthOrWider + + internal val canShowFab + get() = when (paneState.pane) { + ThreePane.Primary -> true + ThreePane.TransientPrimary -> true + ThreePane.Secondary -> false + ThreePane.Tertiary -> false + ThreePane.Overlay -> false + null -> false + } + + internal var scaffoldTargetSize by mutableStateOf(IntSize.Zero) + internal var scaffoldCurrentSize by mutableStateOf(IntSize.Zero) + + internal fun hasMatchedSize(): Boolean = + abs(scaffoldCurrentSize.width - scaffoldTargetSize.width) <= 2 + && abs(scaffoldCurrentSize.height - scaffoldTargetSize.height) <= 2 +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PaneScope.PaneScaffold( + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.background, + snackBarMessages: List = emptyList(), + onSnackBarMessageConsumed: (String) -> Unit = {}, + topBar: @Composable PaneScaffoldState.() -> Unit = {}, + floatingActionButton: @Composable PaneScaffoldState.() -> Unit = {}, + navigationBar: @Composable PaneScaffoldState.() -> Unit = {}, + navigationRail: @Composable PaneScaffoldState.() -> Unit = {}, + content: @Composable PaneScaffoldState.(PaddingValues) -> Unit, +) { + val appState = LocalAppState.current + val snackbarHostState = remember { SnackbarHostState() } + val paneMovableElementSharedTransitionScope = rememberPaneMovableElementSharedTransitionScope() + val paneScaffoldState = + remember(appState, paneMovableElementSharedTransitionScope) { + PaneScaffoldState( + appState = appState, + paneMovableElementSharedTransitionScope = paneMovableElementSharedTransitionScope, + ) + } + + val canAnimatePane = remember { mutableStateOf(true) }.also { + it.value = !appState.isInteractingWithPanes() + } + + RowPaneScaffold( + modifier = modifier, + navigationRail = { + if (paneScaffoldState.canShowNavRail) Box( + modifier = Modifier + .zIndex(2f), + ) { + paneScaffoldState.navigationRail() + } + }, + content = { + Scaffold( + modifier = Modifier + .animateBounds( + lookaheadScope = paneMovableElementSharedTransitionScope, + boundsTransform = remember { + scaffoldBoundsTransform( + paneScaffoldState = paneScaffoldState, + canAnimatePane = canAnimatePane::value + ) + } + ) + .padding( + horizontal = if (appState.filteredPaneOrder.size > 1) 8.dp else 0.dp + ) + .onSizeChanged { + paneScaffoldState.scaffoldCurrentSize = it + }, + containerColor = containerColor, + topBar = { + paneScaffoldState.topBar() + }, + floatingActionButton = { + AnimatedVisibility( + visible = paneScaffoldState.canShowFab, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + content = { + paneScaffoldState.floatingActionButton() + }, + ) + }, + bottomBar = { + AnimatedVisibility( + visible = paneScaffoldState.canShowBottomNavigation, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + content = { + paneScaffoldState.navigationBar() + }, + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + content = { paddingValues -> + paneScaffoldState.content(paddingValues) + }, + ) + } + ) + val updatedMessages = rememberUpdatedState(snackBarMessages.firstOrNull()) + LaunchedEffect(Unit) { + snapshotFlow { updatedMessages.value } + .filterNotNull() + .filterNot(String::isNullOrBlank) + .collect { message -> + snackbarHostState.showSnackbar( + message = message + ) + onSnackBarMessageConsumed(message) + } + } +} + +@Composable +private inline fun RowPaneScaffold( + modifier: Modifier = Modifier, + navigationRail: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + Row( + modifier = modifier, + content = { + Box( + modifier = Modifier + .widthIn(max = 80.dp) + ) { + navigationRail() + } + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + content = { + content() + } + ) + }, + ) +} + +@OptIn(ExperimentalSharedTransitionApi::class) +private fun scaffoldBoundsTransform( + paneScaffoldState: PaneScaffoldState, + canAnimatePane: () -> Boolean, +): BoundsTransform = BoundsTransform { _, targetBounds -> + paneScaffoldState.scaffoldTargetSize = + targetBounds.size.roundToIntSize() + + when (paneScaffoldState.paneState.pane) { + ThreePane.Primary, + ThreePane.Secondary, + ThreePane.Tertiary, + -> if (canAnimatePane()) spring() + else snap() + + ThreePane.TransientPrimary, + -> spring().skipIf(paneScaffoldState::hasMatchedSize) + + ThreePane.Overlay, + null, + -> snap() + } +} + +fun Modifier.paneClip() = + then(PaneClipModifier) + +private val PaneClipModifier = Modifier.clip( + shape = RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + ) +) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt new file mode 100644 index 0000000..539046a --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Adetunji Dahunsi + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.unit.dp +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.threepane.ThreePane + +@Composable +fun Modifier.predictiveBackBackgroundModifier( + paneScope: PaneScope, +): Modifier { + if (paneScope.paneState.pane != ThreePane.TransientPrimary) + return this + + var elevation by remember { mutableStateOf(0.dp) } + LaunchedEffect(Unit) { + animate( + initialValue = 0f, + targetValue = 4f, + animationSpec = spring(stiffness = Spring.StiffnessVeryLow) + ) { value, _ -> elevation = value.dp } + } + return background( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), + shape = RoundedCornerShape(16.dp) + ) + .clip(RoundedCornerShape(16.dp)) + +} diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt similarity index 63% rename from sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt rename to sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt index 2c31897..ec3861a 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt @@ -23,10 +23,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel +import com.tunjid.demo.common.ui.PaneBottomAppBar +import com.tunjid.demo.common.ui.PaneNavigationRail +import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs +import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun chatPaneEntry() = threePaneEntry( @@ -45,18 +48,30 @@ fun chatPaneEntry() = threePaneEntry( chat = destination, ) } - ChatScreen( - movableSharedElementScope = requireThreePaneMovableSharedElementScope(), - state = viewModel.state.collectAsStateWithLifecycle().value, - onAction = viewModel.accept, - modifier = Modifier.fillMaxSize(), - ) - LaunchedEffect(paneState.pane) { - viewModel.accept( - Action.UpdateInPrimaryPane( - isInPrimaryPane = paneState.pane == ThreePane.Primary + PaneScaffold( + modifier = Modifier + .predictiveBackBackgroundModifier(this) + .fillMaxSize(), + content = { + ChatScreen( + movableSharedElementScope = this, + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept, ) - ) - } + LaunchedEffect(paneState.pane) { + viewModel.accept( + Action.UpdateInPrimaryPane( + isInPrimaryPane = paneState.pane == ThreePane.Primary + ) + ) + } + }, + navigationBar = { + PaneBottomAppBar() + }, + navigationRail = { + PaneNavigationRail() + }, + ) }, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt similarity index 63% rename from sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt rename to sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt index 7be32cd..987e5ad 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt @@ -22,13 +22,15 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel +import com.tunjid.demo.common.ui.PaneBottomAppBar +import com.tunjid.demo.common.ui.PaneNavigationRail +import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.ChatsRepository -import com.tunjid.demo.common.ui.data.SampleDestination -import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope +import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.treenav.compose.threepane.threePaneEntry fun chatRoomPaneEntry( -) = threePaneEntry( +) = threePaneEntry( render = { val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { @@ -37,11 +39,23 @@ fun chatRoomPaneEntry( chatsRepository = ChatsRepository ) } - ChatRoomsScreen( - movableSharedElementScope = requireThreePaneMovableSharedElementScope(), - state = viewModel.state.collectAsStateWithLifecycle().value, - onAction = viewModel.accept, - modifier = Modifier.fillMaxSize(), + PaneScaffold( + modifier = Modifier + .predictiveBackBackgroundModifier(this) + .fillMaxSize(), + content = { + ChatRoomsScreen( + movableSharedElementScope = this, + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept, + ) + }, + navigationBar = { + PaneBottomAppBar() + }, + navigationRail = { + PaneNavigationRail() + }, ) } ) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt similarity index 60% rename from sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt rename to sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt index 1434f13..32638d2 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt @@ -16,18 +16,22 @@ package com.tunjid.demo.common.ui.me +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.PaneBottomAppBar +import com.tunjid.demo.common.ui.PaneNavigationRail +import com.tunjid.demo.common.ui.PaneScaffold +import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.profile.ProfileScreen import com.tunjid.demo.common.ui.profile.ProfileViewModel -import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun mePaneEntry( -) = threePaneEntry( +) = threePaneEntry( render = { val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { @@ -37,10 +41,23 @@ fun mePaneEntry( roomName = null, ) } - ProfileScreen( - movableSharedElementScope = requireThreePaneMovableSharedElementScope(), - state = viewModel.state.collectAsStateWithLifecycle().value, - onAction = viewModel.accept + PaneScaffold( + modifier = Modifier + .predictiveBackBackgroundModifier(this) + .fillMaxSize(), + content = { + ProfileScreen( + movableSharedElementScope = this, + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept, + ) + }, + navigationBar = { + PaneBottomAppBar() + }, + navigationRail = { + PaneNavigationRail() + }, ) } ) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt similarity index 70% rename from sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt rename to sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt index e74b3f7..ec07cd4 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Strategy.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt @@ -22,10 +22,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel +import com.tunjid.demo.common.ui.PaneBottomAppBar +import com.tunjid.demo.common.ui.PaneNavigationRail +import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs +import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.transforms.requireThreePaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun profilePaneEntry() = threePaneEntry( @@ -47,11 +50,24 @@ fun profilePaneEntry() = threePaneEntry( roomName = destination.roomName, ) } - ProfileScreen( - movableSharedElementScope = requireThreePaneMovableSharedElementScope(), - state = viewModel.state.collectAsStateWithLifecycle().value, - onAction = viewModel.accept, - modifier = Modifier.fillMaxSize(), + PaneScaffold( + modifier = Modifier + .predictiveBackBackgroundModifier(this) + .fillMaxSize(), + content = { + ProfileScreen( + movableSharedElementScope = this, + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept, + modifier = Modifier.fillMaxSize() + ) + }, + navigationBar = { + PaneBottomAppBar() + }, + navigationRail = { + PaneNavigationRail() + }, ) }, ) \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 95e7e3f..80776a3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,8 +46,8 @@ include( ":library:treenav", ":library:strings", ":library:compose", + ":library:compose-threepane", ":sample:android", ":sample:common", ":sample:desktop", ) -