diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/PaneModifierConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/PaneModifierConfiguration.kt new file mode 100644 index 0000000..9c8015f --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/PaneModifierConfiguration.kt @@ -0,0 +1,52 @@ +/* + * 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.configurations + +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.PanedNavHostConfiguration +import com.tunjid.treenav.compose.delegated +import com.tunjid.treenav.compose.paneStrategy + +/** + * A [PanedNavHostConfiguration] that allows for centrally defining the [Modifier] for + * each [Pane] displayed within it. + * + * @param paneModifier a lambda for specifying the [Modifier] for each [Pane] in a [PaneScope]. + */ +fun PanedNavHostConfiguration< + Pane, + NavigationState, + Destination + >.paneModifierConfiguration( + paneModifier: PaneScope.() -> Modifier = { Modifier }, +): PanedNavHostConfiguration = delegated { + val originalTransform = strategyTransform(it) + paneStrategy( + transitions = originalTransform.transitions, + paneMapping = originalTransform.paneMapper, + render = render@{ destination -> + Box( + modifier = paneModifier() + ) { + originalTransform.render(this@render, destination) + } + } + ) +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/PredictiveBackConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/PredictiveBackConfiguration.kt new file mode 100644 index 0000000..eec1449 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/PredictiveBackConfiguration.kt @@ -0,0 +1,76 @@ +/* + * 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.configurations + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PanedNavHostConfiguration +import com.tunjid.treenav.compose.delegated +import com.tunjid.treenav.compose.paneStrategy +import com.tunjid.treenav.compose.threepane.ThreePane + +/** + * An [PanedNavHostConfiguration] that moves the destination in a [ThreePane.Primary] pane, to + * to the [ThreePane.TransientPrimary] pane when a predictive back gesture is in progress. + * + * @param isPreviewingBack provides the state of the predictive back gesture. + * True if the gesture is ongoing. + * @param backPreviewTransform provides the [NavigationState] if the predictive back gesture + * were to be completed. + */ +inline fun PanedNavHostConfiguration< + ThreePane, + NavigationState, + Destination + >.predictiveBackConfiguration( + isPreviewingBack: State, + crossinline backPreviewTransform: NavigationState.() -> NavigationState, +): PanedNavHostConfiguration { + var lastPrimaryDestination by mutableStateOf(null) + return delegated( + destinationTransform = { navigationState -> + val current = destinationTransform(navigationState) + lastPrimaryDestination = current + if (isPreviewingBack.value) destinationTransform(navigationState.backPreviewTransform()) + else current + }, + strategyTransform = { destination -> + val originalStrategy = strategyTransform(destination) + paneStrategy( + transitions = originalStrategy.transitions, + paneMapping = paneMapper@{ inner -> + val originalMapping = originalStrategy.paneMapper(inner) + val isPreviewing by isPreviewingBack + if (!isPreviewing) return@paneMapper originalMapping + // Back is being previewed, therefore the original mapping is already for back. + // Pass the previous primary value into transient. + val transientDestination = checkNotNull(lastPrimaryDestination) { + "Attempted to show last destination without calling destination transform" + } + val paneMapping = strategyTransform(transientDestination) + .paneMapper(transientDestination) + val transient = paneMapping[ThreePane.Primary] + originalMapping + (ThreePane.TransientPrimary to transient) + }, + render = originalStrategy.render + ) + } + ) +} diff --git a/sample/android/src/main/AndroidManifest.xml b/sample/android/src/main/AndroidManifest.xml index 025cb65..afeeffe 100644 --- a/sample/android/src/main/AndroidManifest.xml +++ b/sample/android/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:enableOnBackInvokedCallback="true" android:theme="@style/Theme.Tyler"> -> + try { + progress.collectIndexed { index, backEvent -> +// if (index == 0) onStarted() + val touchOffset = Offset(backEvent.touchX, backEvent.touchY) + val progressFraction = backEvent.progress + appState.updatePredictiveBack(touchOffset, progressFraction) + } + appState.goBack() + } catch (e: CancellationException) { + appState.cancelPredictiveBack() + } + } } } } 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 8782a08..51df51e 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 @@ -18,26 +18,32 @@ package com.tunjid.demo.common.ui import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.round import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberPanedNavHostState import com.tunjid.demo.common.ui.chat.chatPaneStrategy import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy @@ -51,20 +57,23 @@ import com.tunjid.treenav.compose.PanedNavHost import com.tunjid.treenav.compose.PanedNavHostConfiguration import com.tunjid.treenav.compose.SavedStatePanedNavHostState import com.tunjid.treenav.compose.configurations.animatePaneBoundsConfiguration +import com.tunjid.treenav.compose.configurations.paneModifierConfiguration import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.panedNavHostConfiguration import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.configurations.canAnimateOnStartingFrames +import com.tunjid.treenav.compose.threepane.configurations.predictiveBackConfiguration import com.tunjid.treenav.compose.threepane.configurations.threePanedMovableSharedElementConfiguration import com.tunjid.treenav.compose.threepane.configurations.threePanedNavHostConfiguration import com.tunjid.treenav.current +import com.tunjid.treenav.pop import com.tunjid.treenav.popToRoot import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.math.roundToInt -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3AdaptiveApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SampleApp( appState: SampleAppState = remember { SampleAppState() }, @@ -83,6 +92,7 @@ fun SampleApp( ) { SharedTransitionScope { sharedTransitionModifier -> val windowWidthDp = remember { mutableIntStateOf(0) } + val surfaceColor = MaterialTheme.colorScheme.surface val density = LocalDensity.current val movableSharedElementHostState = remember { MovableSharedElementHostState( @@ -90,12 +100,28 @@ fun SampleApp( canAnimateOnStartingFrames = PaneState::canAnimateOnStartingFrames ) } + PanedNavHost( state = appState.rememberPanedNavHostState { this + .paneModifierConfiguration { + if (paneState.pane == ThreePane.TransientPrimary) Modifier + .background(surfaceColor) + .fillMaxSize() + .predictiveBackModifier( + touchOffsetState = appState.backTouchOffsetState, + progressState = appState.backProgressFractionState + ) + else Modifier.fillMaxSize() + + } .threePanedNavHostConfiguration( windowWidthDpState = windowWidthDp ) + .predictiveBackConfiguration( + isPreviewingBack = appState.predictiveBackStatus, + backPreviewTransform = MultiStackNav::pop, + ) .threePanedMovableSharedElementConfiguration( movableSharedElementHostState = movableSharedElementHostState ) @@ -141,6 +167,7 @@ fun SampleApp( .fillMaxHeight() ) { Destination(pane) + if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary) } } } @@ -157,12 +184,16 @@ class SampleAppState( private val navigationState = mutableStateOf( navigationRepository.navigationStateFlow.value ) - val currentNavigation by navigationState - private val panedNavHostConfiguration = sampleAppNavHostConfiguration( navigationState ) + val backTouchOffsetState = mutableStateOf(IntOffset.Zero) + val backProgressFractionState = mutableFloatStateOf(Float.NaN) + + val currentNavigation by navigationState + val predictiveBackStatus = derivedStateOf { !backProgressFractionState.value.isNaN() } + fun setTab(destination: SampleDestination.NavTabs) { navigationRepository.navigate { if (it.currentIndex == destination.ordinal) it.popToRoot() @@ -170,6 +201,24 @@ class SampleAppState( } } + fun updatePredictiveBack( + touchOffset: Offset, + fraction: Float, + ) { + backTouchOffsetState.value = touchOffset.round() + backProgressFractionState.value = fraction + } + + fun cancelPredictiveBack() { + backTouchOffsetState.value = IntOffset.Zero + backProgressFractionState.value = Float.NaN + } + + fun goBack() { + cancelPredictiveBack() + navigationRepository.navigate(MultiStackNav::pop) + } + companion object { @Composable fun SampleAppState.rememberPanedNavHostState( 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..38edd40 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt @@ -0,0 +1,85 @@ +/* + * 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.demo.common.ui + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastRoundToInt +import kotlin.math.roundToInt + +// Previews back content as specified by the material motion spec for Android predictive back: +// https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#motion-specs +fun Modifier.predictiveBackModifier( + touchOffsetState: State, + progressState: State, +): Modifier = layout { measurable, constraints -> + val touchOffset by touchOffsetState + val progress by progressState + val scale = 1f - (progress * 0.15F) + + val placeable = measurable.measure( + if (progress.isNaN()) constraints + else constraints.copy( + maxWidth = (constraints.maxWidth * scale).roundToInt(), + minWidth = (constraints.minWidth * scale).roundToInt(), + maxHeight = (constraints.maxHeight * scale).roundToInt(), + minHeight = (constraints.minHeight * scale).roundToInt(), + ) + ) + val paneWidth = (placeable.width * scale).fastRoundToInt() + val paneHeight = (placeable.height * scale).fastRoundToInt() + + if (progress.isNaN() || paneWidth == 0 || paneHeight == 0) return@layout layout( + paneWidth, + paneHeight + ) { + placeable.place(0, 0) + } + + val scaledWidth = paneWidth * scale + val spaceOnEachSide = (paneWidth - scaledWidth) / 2 + val margin = (BACK_PREVIEW_PADDING * progress).dp.roundToPx() + val isFromLeft = true + + val xOffset = ((spaceOnEachSide - margin) * when { + isFromLeft -> 1 + else -> -1 + }).toInt() + + val maxYShift = ((paneHeight / 20) - BACK_PREVIEW_PADDING) + val isOrientedHorizontally = paneWidth > paneHeight + val screenSize = when { + isOrientedHorizontally -> paneWidth + else -> paneHeight + }.dp.roundToPx() + val touchPoint = when { + isOrientedHorizontally -> touchOffset.x + else -> touchOffset.y + } + val verticalProgress = (touchPoint / screenSize) - 0.5f + val yOffset = (verticalProgress * maxYShift).roundToInt() + + layout(placeable.width, placeable.height) { + placeable.placeRelative(x = xOffset, y = -yOffset) + } +} + +private const val BACK_PREVIEW_PADDING = 8