From 0553df3b80a9ad9c05976ba0e76e2a42426cf5ba Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 24 Nov 2024 15:20:38 -0800 Subject: [PATCH] Use Compose state for back previews --- gradle/libs.versions.toml | 2 +- libraryVersion.properties | 6 +- sample/android/build.gradle.kts | 2 + .../java/com/tunjid/tyler/MainActivity.kt | 38 +++++-- .../com/tunjid/demo/common/ui/DemoApp.kt | 104 ++++++++---------- .../tunjid/demo/common/ui/PredictiveBack.kt | 82 -------------- .../com/tunjid/demo/common/ui/ProfilePhoto.kt | 4 +- sample/common/src/iosMain/kotlin/main.ios.kt | 4 +- .../jvmMain/kotlin/com/tunjid/demo/Main.kt | 4 +- 9 files changed, 84 insertions(+), 162 deletions(-) delete mode 100644 sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5715b74..b74b4d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ kotlinxCoroutines = "1.9.0" kotlinxDatetime = "0.6.1" lifecycle-runtime = "2.8.6" tunjidStateHolder = "1.1.0" -tunjidComposables = "0.0.9" +tunjidComposables = "0.0.11" junit = "4.13.2" runner = "1.0.2" espressoCore = "3.0.2" diff --git a/libraryVersion.properties b/libraryVersion.properties index a9cb5e2..ca7f099 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,6 +14,6 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.11 -strings_version=0.0.11 -compose_version=0.0.11 \ No newline at end of file +treenav_version=0.0.12 +strings_version=0.0.12 +compose_version=0.0.12 \ No newline at end of file diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts index 7d7c093..b53fd89 100644 --- a/sample/android/build.gradle.kts +++ b/sample/android/build.gradle.kts @@ -52,4 +52,6 @@ dependencies { implementation(libs.jetbrains.compose.animation) implementation(libs.google.material) + + implementation(libs.tunjid.composables) } diff --git a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt index 5cfd09f..3b7a87e 100644 --- a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt +++ b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt @@ -24,11 +24,12 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.round +import com.tunjid.demo.common.ui.App +import com.tunjid.demo.common.ui.AppState import com.tunjid.demo.common.ui.AppTheme -import com.tunjid.demo.common.ui.SampleApp -import com.tunjid.demo.common.ui.SampleAppState import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectIndexed import kotlin.coroutines.cancellation.CancellationException class MainActivity : AppCompatActivity() { @@ -37,20 +38,33 @@ class MainActivity : AppCompatActivity() { enableEdgeToEdge() setContent { AppTheme { - val appState = remember { SampleAppState() } - SampleApp(appState) + val appState = remember { AppState() } + App(appState) - PredictiveBackHandler { progress: Flow -> + PredictiveBackHandler { backEvents: Flow -> try { - progress.collectIndexed { index, backEvent -> -// if (index == 0) onStarted() - val touchOffset = Offset(backEvent.touchX, backEvent.touchY) - val progressFraction = backEvent.progress - appState.updatePredictiveBack(touchOffset, progressFraction) + backEvents.collect { backEvent -> + appState.backPreviewState.apply { + atStart = backEvent.swipeEdge == BackEventCompat.EDGE_LEFT + progress = backEvent.progress + pointerOffset = Offset( + x = backEvent.touchX, + y = backEvent.touchY + ).round() + } } + // Dismiss back preview + appState.backPreviewState.apply { + progress = Float.NaN + pointerOffset = IntOffset.Zero + } + // Pop navigation appState.goBack() } catch (e: CancellationException) { - appState.cancelPredictiveBack() + appState.backPreviewState.apply { + progress = Float.NaN + pointerOffset = IntOffset.Zero + } } } } 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 06b48e4..a0a61c9 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 @@ -59,16 +59,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.round +import com.tunjid.composables.backpreview.BackPreviewState +import com.tunjid.composables.backpreview.backPreview import com.tunjid.composables.splitlayout.SplitLayout import com.tunjid.composables.splitlayout.SplitLayoutState -import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberPanedNavHostState +import com.tunjid.demo.common.ui.AppState.Companion.rememberPanedNavHostState import com.tunjid.demo.common.ui.chat.chatPaneStrategy import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy import com.tunjid.demo.common.ui.data.NavigationRepository @@ -78,6 +77,7 @@ import com.tunjid.demo.common.ui.profile.profilePaneStrategy import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.compose.PanedNavHost import com.tunjid.treenav.compose.PanedNavHostConfiguration +import com.tunjid.treenav.compose.PanedNavHostScope import com.tunjid.treenav.compose.SavedStatePanedNavHostState import com.tunjid.treenav.compose.configurations.animatePaneBoundsConfiguration import com.tunjid.treenav.compose.configurations.paneModifierConfiguration @@ -96,8 +96,8 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalSharedTransitionApi::class) @Composable -fun SampleApp( - appState: SampleAppState = remember { SampleAppState() }, +fun App( + appState: AppState = remember { AppState() }, ) { NavigationSuiteScaffold( navigationSuiteItems = { @@ -112,25 +112,8 @@ fun SampleApp( } ) { SharedTransitionScope { sharedTransitionModifier -> - val order = remember { - listOf( - ThreePane.Tertiary, - ThreePane.Secondary, - ThreePane.Primary, - ) - } - val splitLayoutState = remember { - SplitLayoutState( - orientation = Orientation.Horizontal, - maxCount = order.size, - keyAtIndex = { index -> - val indexDiff = order.size - visibleCount - order[index + indexDiff] - } - ) - } - val surfaceColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - animateDpAsState(if (appState.predictiveBackStatus.value) 16.dp else 0.dp).value + val backPreviewSurfaceColor = MaterialTheme.colorScheme.surfaceColorAtElevation( + animateDpAsState(if (appState.isPreviewingBack) 16.dp else 0.dp).value ) val density = LocalDensity.current val movableSharedElementHostState = remember { @@ -153,21 +136,20 @@ fun SampleApp( .paneModifierConfiguration { if (paneState.pane == ThreePane.TransientPrimary) Modifier .fillMaxSize() - .predictiveBackModifier( - touchOffsetState = appState.backTouchOffsetState, - progressState = appState.backProgressFractionState - ) - .background(surfaceColor, RoundedCornerShape(16.dp)) + .backPreview(appState.backPreviewState) + .background(backPreviewSurfaceColor, RoundedCornerShape(16.dp)) else Modifier .fillMaxSize() } .threePanedNavHostConfiguration( windowWidthState = derivedStateOf { - splitLayoutState.size + appState.splitLayoutState.size } ) .predictiveBackConfiguration( - isPreviewingBack = appState.predictiveBackStatus, + isPreviewingBack = derivedStateOf { + appState.isPreviewingBack + }, backPreviewTransform = MultiStackNav::pop, ) .threePanedMovableSharedElementConfiguration( @@ -189,18 +171,18 @@ fun SampleApp( ) }, ) { - val filteredOrder by remember { - derivedStateOf { order.filter { nodeFor(it) != null } } + val filteredPaneOrder by remember { + derivedStateOf { appState.filteredPaneOrder(this) } } - splitLayoutState.visibleCount = filteredOrder.size + appState.splitLayoutState.visibleCount = filteredPaneOrder.size SplitLayout( - state = splitLayoutState, + state = appState.splitLayoutState, modifier = Modifier .fillMaxSize() then sharedTransitionModifier, itemSeparators = { paneIndex, offset -> PaneSeparator( - splitLayoutState = splitLayoutState, + splitLayoutState = appState.splitLayoutState, interactionSource = appState.paneInteractionSourceAt(paneIndex), index = paneIndex, density = density, @@ -208,7 +190,7 @@ fun SampleApp( ) }, itemContent = { index -> - val pane = filteredOrder[index] + val pane = filteredPaneOrder[index] Destination(pane) if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary) } @@ -281,7 +263,7 @@ fun InteractionSource.isActive(): Boolean { } @Stable -class SampleAppState( +class AppState( private val navigationRepository: NavigationRepository = NavigationRepository ) { @@ -292,12 +274,32 @@ class SampleAppState( navigationState ) private val paneInteractionSourceList = mutableStateListOf() - - val backTouchOffsetState = mutableStateOf(IntOffset.Zero) - val backProgressFractionState = mutableFloatStateOf(Float.NaN) + private val paneRenderOrder = listOf( + ThreePane.Secondary, + ThreePane.Primary, + ) val currentNavigation by navigationState - val predictiveBackStatus = derivedStateOf { !backProgressFractionState.value.isNaN() } + val backPreviewState = BackPreviewState() + val splitLayoutState = SplitLayoutState( + orientation = Orientation.Horizontal, + maxCount = paneRenderOrder.size, + minSize = 10.dp, + keyAtIndex = { index -> + val indexDiff = paneRenderOrder.size - visibleCount + paneRenderOrder[index + indexDiff] + } + ) + + internal val isPreviewingBack + get() = !backPreviewState.progress.isNaN() + + fun filteredPaneOrder( + panedNavHostScope: PanedNavHostScope + ): List { + val order = paneRenderOrder.filter { panedNavHostScope.nodeFor(it) != null } + return order + } fun setTab(destination: SampleDestination.NavTabs) { navigationRepository.navigate { @@ -317,27 +319,13 @@ class SampleAppState( fun isInteractingWithPanes(): Boolean = paneInteractionSourceList.any { it.isActive() } - 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( + fun AppState.rememberPanedNavHostState( configurationBlock: PanedNavHostConfiguration< ThreePane, MultiStackNav, 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 deleted file mode 100644 index af2d458..0000000 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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(), - ) - ) - if (progress.isNaN()) return@layout layout(placeable.width, placeable.height) { - placeable.place(0, 0) - } - - val paneWidth = (placeable.width * scale).fastRoundToInt() - val paneHeight = (placeable.height * scale).fastRoundToInt() - - val scaledWidth = paneWidth * scale - val spaceOnEachSide = (paneWidth - scaledWidth) / 2 - val margin = (BACK_PREVIEW_PADDING * progress).dp.roundToPx() - val isFromLeft = touchOffset.x < paneWidth / 2 - - 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 diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt index 940eaad..cd478e1 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tunjid.composables.ui.interpolate +import com.tunjid.composables.ui.animate import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource import treenavigation.sample.common.generated.resources.Res @@ -39,7 +39,7 @@ fun ProfilePhoto( Image( modifier = modifier .clip(RoundedCornerShape(animateDpAsState(args.cornerRadius).value)), - contentScale = args.contentScale.interpolate(), + contentScale = args.contentScale.animate(), contentDescription = args.contentDescription, painter = painterResource(args.profilePhotoResource()) ) diff --git a/sample/common/src/iosMain/kotlin/main.ios.kt b/sample/common/src/iosMain/kotlin/main.ios.kt index 836268c..52228e6 100644 --- a/sample/common/src/iosMain/kotlin/main.ios.kt +++ b/sample/common/src/iosMain/kotlin/main.ios.kt @@ -1,9 +1,9 @@ import androidx.compose.ui.window.ComposeUIViewController import com.tunjid.demo.common.ui.AppTheme -import com.tunjid.demo.common.ui.SampleApp +import com.tunjid.demo.common.ui.App fun MainViewController() = ComposeUIViewController { AppTheme { - SampleApp() + App() } } diff --git a/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt b/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt index d19c188..ab8762a 100644 --- a/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt +++ b/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.tunjid.demo.common.ui.AppTheme -import com.tunjid.demo.common.ui.SampleApp +import com.tunjid.demo.common.ui.App fun main() { application { @@ -35,7 +35,7 @@ fun main() { title = "Tiling Demo" ) { AppTheme { - SampleApp() + App() } } }