diff --git a/library/adaptive/build.gradle.kts b/library/adaptive/build.gradle.kts index 13135af..1ba0099 100644 --- a/library/adaptive/build.gradle.kts +++ b/library/adaptive/build.gradle.kts @@ -36,7 +36,6 @@ kotlin { implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.foundation.layout) -// implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.lifecycle.runtime) implementation(libs.jetbrains.lifecycle.runtime.compose) @@ -44,6 +43,11 @@ kotlin { implementation(libs.jetbrains.lifecycle.viewmodel.compose) } } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } val jvmMain by getting val jvmTest by getting diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt index dbb3824..5c1e978 100644 --- a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt @@ -7,21 +7,35 @@ import com.tunjid.treenav.Node */ interface AdaptiveNavigationState { + /** + * The current [Destination] in this [pane]. + * @param pane the [Pane] to query. + */ fun destinationFor( pane: Pane, ): Destination? - fun adaptationIn( + /** + * Adaptations involving this [pane] after the last navigation state change. + * @param pane the affected [Pane]. + */ + fun adaptationsIn( pane: Pane, - ): Adaptation? + ): Set } /** * A description of the process that the layout undertook to adapt to its new configuration. */ sealed class Adaptation { + + /** + * Destinations remained the same in the pane + */ + data object Same : Adaptation() + /** - * Destinations were changed in panes + * Destinations were changed in the pane */ data object Change : Adaptation() diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt index 6d941e2..b4ef94e 100644 --- a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt @@ -76,7 +76,7 @@ internal class AnimatedAdaptivePaneScope( sealed interface AdaptivePaneState { val currentDestination: Destination? val pane: Pane? - val adaptation: Adaptation + val adaptations: Set } /** @@ -87,7 +87,7 @@ internal data class SlotPaneState( val previousDestination: Destination?, override val currentDestination: Destination?, override val pane: Pane?, - override val adaptation: Adaptation, + override val adaptations: Set, ) : AdaptivePaneState /** diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt index 6797ba2..51ca896 100644 --- a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt @@ -54,9 +54,9 @@ interface AdaptiveNavHostScope { pane: Pane ) - fun adaptationIn( + fun adaptationsIn( pane: Pane, - ): Adaptation? + ): Set fun nodeFor( pane: Pane, @@ -145,9 +145,9 @@ class SavedStateAdaptiveNavHostState( slotsToRoutes[slot]?.invoke() } - override fun adaptationIn( + override fun adaptationsIn( pane: Pane - ): Adaptation? = adaptiveNavigationState.adaptationIn(pane) + ): Set = adaptiveNavigationState.adaptationsIn(pane) override fun nodeFor( pane: Pane diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt index cdb1367..2a8a891 100644 --- a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt @@ -75,8 +75,7 @@ internal data class SlotBasedAdaptiveNavigationState( currentDestination = node, previousDestination = previousPanesToDestinations[pane], pane = pane, - adaptation = swapAdaptations.firstOrNull { pane in it } - ?: Adaptation.Change, + adaptations = pane?.let(::adaptationsIn) ?: emptySet(), ) } @@ -106,9 +105,16 @@ internal data class SlotBasedAdaptiveNavigationState( pane: Pane ): Destination? = panesToDestinations[pane] - override fun adaptationIn( + override fun adaptationsIn( pane: Pane - ): Adaptation? = swapAdaptations.firstOrNull { pane in it } + ): Set { + val swaps = swapAdaptations.filter { pane in it } + return if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { + previousPanesToDestinations[pane]?.id -> setOf(Adaptation.Same) + else -> setOf(Adaptation.Change) + } + else swaps.toSet() + } } /** @@ -178,7 +184,7 @@ internal fun SlotBasedAdaptiveNavigationState.adaptTo( panesToNodes.mapValues { it.value?.id } -> previous.swapAdaptations else -> swapAdaptations }, - previousPanesToDestinations = previous.previousPanesToDestinations.keys.associateWith( + previousPanesToDestinations = previous.panesToDestinations.keys.associateWith( valueSelector = previous::destinationFor ), destinationIdsToAdaptiveSlots = nodeIdsToAdaptiveSlots, diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt index 508f0f8..0d71440 100644 --- a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt @@ -62,14 +62,14 @@ fun threePaneAdaptiveNodeConfiguration( val state = paneState when (state.pane) { ThreePane.Primary, - ThreePane.Secondary -> when (state.adaptation) { + ThreePane.Secondary -> when (state.adaptations) { ThreePane.PrimaryToSecondary, ThreePane.SecondaryToPrimary -> NoTransition else -> DefaultTransition } - ThreePane.TransientPrimary -> when (state.adaptation) { + ThreePane.TransientPrimary -> when (state.adaptations) { ThreePane.PrimaryToTransient -> when (state.pane) { ThreePane.Secondary -> DefaultTransition else -> NoTransition diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt index 7b3a5c4..9dcc9fe 100644 --- a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt @@ -120,4 +120,4 @@ fun AdaptivePaneState?.canAnimateOnStartingFrames() = private val AdaptivePaneScope.isPreviewingBack: Boolean get() = paneState.pane == ThreePane.Primary - && paneState.adaptation == ThreePane.PrimaryToTransient \ No newline at end of file + && paneState.adaptations.contains(ThreePane.PrimaryToTransient) \ No newline at end of file diff --git a/library/adaptive/src/commonTest/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationStateTest.kt b/library/adaptive/src/commonTest/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationStateTest.kt new file mode 100644 index 0000000..d395002 --- /dev/null +++ b/library/adaptive/src/commonTest/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationStateTest.kt @@ -0,0 +1,542 @@ +/* + * 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.adaptive + +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.threepane.ThreePane +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/* + * 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. + */ + +data class TestNode(val name: String) : Node { + override val id: String get() = name +} + +class SlotBasedAdaptiveNavigationStateTest { + + private lateinit var subject: SlotBasedAdaptiveNavigationState + private lateinit var panes: List + private lateinit var slots: Set + + + @BeforeTest + fun setup() { + panes = ThreePane.entries.toList() + slots = List(size = panes.size, init = ::Slot).toSet() + subject = SlotBasedAdaptiveNavigationState.initial( + slots = slots + ) + } + + @Test + fun testFirstSinglePaneAdaptation() { + subject.testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ) + ) + .apply { + assertEquals( + expected = destinationFor(ThreePane.Primary), + actual = TestNode(name = "A"), + ) + assertEquals( + expected = adaptationsIn(ThreePane.Primary), + actual = setOf(Adaptation.Change), + ) + assertEquals( + expected = slotFor(ThreePane.Primary), + actual = Slot(0), + ) + } + } + + @Test + fun testFirstTriplePaneAdaptation() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.Secondary to TestNode(name = "B"), + ThreePane.Tertiary to TestNode(name = "C"), + ) + ) + .apply { + // Primary + assertEquals( + expected = destinationFor(ThreePane.Primary), + actual = TestNode(name = "A") + ) + assertEquals( + expected = adaptationsIn(ThreePane.Primary), + actual = setOf(Adaptation.Change) + ) + assertEquals( + expected = slotFor(ThreePane.Primary), + actual = Slot(0) + ) + + // Secondary + assertEquals( + expected = destinationFor(ThreePane.Secondary), + actual = TestNode(name = "B") + ) + assertEquals( + expected = adaptationsIn(ThreePane.Secondary), + actual = setOf(Adaptation.Change) + ) + assertEquals( + expected = slotFor(ThreePane.Secondary), + actual = Slot(1) + ) + + // Tertiary + assertEquals( + expected = destinationFor(ThreePane.Tertiary), + actual = TestNode(name = "C") + ) + assertEquals( + expected = adaptationsIn(ThreePane.Tertiary), + actual = setOf(Adaptation.Change) + ) + assertEquals( + expected = slotFor(ThreePane.Tertiary), + actual = Slot(2) + ) + } + } + + @Test + fun testSameAdaptationInSinglePane() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ) + ) + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ) + ) + .apply { + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = setOf(Adaptation.Same), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.Primary), + ) + } + } + + @Test + fun testSameAdaptationInThreePanes() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.Secondary to TestNode(name = "B"), + ThreePane.Tertiary to TestNode(name = "C"), + ) + ) + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.Secondary to TestNode(name = "B"), + ThreePane.Tertiary to TestNode(name = "C"), + ) + ) + .apply { + // Primary + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = setOf(Adaptation.Same), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.Primary), + ) + + // Secondary + assertEquals( + expected = TestNode(name = "B"), + actual = destinationFor(ThreePane.Secondary), + ) + assertEquals( + expected = setOf(Adaptation.Same), + actual = adaptationsIn(ThreePane.Secondary), + ) + assertEquals( + expected = Slot(1), + actual = slotFor(ThreePane.Secondary), + ) + + // Tertiary + assertEquals( + expected = TestNode(name = "C"), + actual = destinationFor(ThreePane.Tertiary), + ) + assertEquals( + expected = setOf(Adaptation.Same), + actual = adaptationsIn(ThreePane.Tertiary), + ) + assertEquals( + expected = Slot(2), + actual = slotFor(ThreePane.Tertiary), + ) + } + } + + @Test + fun testChangeAdaptationInThreePanes() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.Secondary to TestNode(name = "B"), + ThreePane.Tertiary to TestNode(name = "C"), + ) + ) + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "B"), + ThreePane.Secondary to TestNode(name = "C"), + ThreePane.Tertiary to TestNode(name = "A"), + ) + ) + .apply { + // Primary + assertEquals( + expected = TestNode(name = "B"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.Tertiary), + Adaptation.Swap(from = ThreePane.Secondary, to = ThreePane.Primary), + ), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = Slot(1), + actual = slotFor(ThreePane.Primary), + ) + + // Secondary + assertEquals( + expected = TestNode(name = "C"), + actual = destinationFor(ThreePane.Secondary), + ) + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Tertiary, to = ThreePane.Secondary), + Adaptation.Swap(from = ThreePane.Secondary, to = ThreePane.Primary), + ), + actual = adaptationsIn(ThreePane.Secondary), + ) + assertEquals( + expected = Slot(2), + actual = slotFor(ThreePane.Secondary), + ) + + // Tertiary + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.Tertiary), + ) + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Tertiary, to = ThreePane.Secondary), + Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.Tertiary), + ), + actual = adaptationsIn(ThreePane.Tertiary), + ) + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.Tertiary), + ) + } + } + + @Test + fun testListToListDetailAdaptation() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ) + ) + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "B"), + ThreePane.Secondary to TestNode(name = "A"), + ) + ) + .apply { + // Destination assertions + assertEquals( + expected = TestNode(name = "B"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.Secondary), + ) + + // Adaptation assertions + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.Secondary), + ), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = setOf(ThreePane.PrimaryToSecondary), + actual = adaptationsIn(ThreePane.Secondary), + ) + + // Slot assertions + assertEquals( + // Secondary should reuse slot 0 + expected = Slot(0), + actual = slotFor(ThreePane.Secondary), + ) + assertEquals( + expected = Slot(1), + actual = slotFor(ThreePane.Primary), + ) + } + } + + @Test + fun testSinglePanePredictiveBackAdaptation() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ) + ) + .apply { + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.Primary), + ) + } + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "B"), + ) + ) + .apply { + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.Primary), + ) + } + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.TransientPrimary to TestNode(name = "B"), + ) + ) + .apply { + // Destination assertions + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = TestNode(name = "B"), + actual = destinationFor(ThreePane.TransientPrimary), + ) + + // Adaptation assertions + assertEquals( + expected = setOf(ThreePane.PrimaryToTransient), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = setOf(ThreePane.PrimaryToTransient), + actual = adaptationsIn(ThreePane.TransientPrimary), + ) + + // Slot assertions + assertEquals( + expected = Slot(1), + actual = slotFor(ThreePane.Primary), + ) + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.TransientPrimary), + ) + } + } + + @Test + fun testDoublePaneToSinglePanePredictiveBackAdaptation() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.Secondary to TestNode(name = "B"), + ) + ) + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "C"), + ThreePane.TransientPrimary to TestNode(name = "A"), + ) + ) + .apply { + // Destination assertions + assertEquals( + expected = TestNode(name = "C"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.TransientPrimary), + ) + + // Adaptation assertions + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.TransientPrimary), + ), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = setOf(ThreePane.PrimaryToTransient), + actual = adaptationsIn(ThreePane.TransientPrimary), + ) + + // Slot assertions + assertEquals( + expected = Slot(1), + actual = slotFor(ThreePane.Primary), + ) + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.TransientPrimary), + ) + assertEquals( + expected = null, + actual = slotFor(ThreePane.Secondary), + ) + } + } + + @Test + fun testDoublePaneToDoublePanePredictiveBackAdaptation() { + subject + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "A"), + ThreePane.Secondary to TestNode(name = "B"), + ) + ) + .testAdaptTo( + panesToNodes = mapOf( + ThreePane.Primary to TestNode(name = "C"), + ThreePane.Secondary to TestNode(name = "D"), + ThreePane.TransientPrimary to TestNode(name = "A"), + ) + ) + .apply { + // Destination assertions + assertEquals( + expected = TestNode(name = "C"), + actual = destinationFor(ThreePane.Primary), + ) + assertEquals( + expected = TestNode(name = "D"), + actual = destinationFor(ThreePane.Secondary), + ) + assertEquals( + expected = TestNode(name = "A"), + actual = destinationFor(ThreePane.TransientPrimary), + ) + + // Adaptation assertions + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.TransientPrimary) + ), + actual = adaptationsIn(ThreePane.Primary), + ) + assertEquals( + expected = setOf(Adaptation.Change), + actual = adaptationsIn(ThreePane.Secondary), + ) + assertEquals( + expected = setOf( + Adaptation.Swap(from = ThreePane.Primary, to = ThreePane.TransientPrimary) + ), + actual = adaptationsIn(ThreePane.TransientPrimary), + ) + + // Slot assertions + assertEquals( + expected = Slot(1), + actual = slotFor(ThreePane.Primary), + ) + assertEquals( + expected = Slot(2), + actual = slotFor(ThreePane.Secondary), + ) + assertEquals( + expected = Slot(0), + actual = slotFor(ThreePane.TransientPrimary), + ) + } + } + + private fun SlotBasedAdaptiveNavigationState.testAdaptTo( + panesToNodes: Map + ) = adaptTo( + slots = slots, + backStackIds = emptySet(), + panesToNodes = panesToNodes + ) +} +