From e716ad47082758affd9a6c01b6d9365dd7a351a2 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 29 Sep 2026 19:56:23 +0100 Subject: [PATCH 1/7] shared element morph blur --- Jetchat/app/build.gradle.kts | 11 +- .../example/compose/jetchat/NavigationTest.kt | 45 +-- .../example/compose/jetchat/NavActivity.kt | 115 +++++-- .../jetchat/conversation/Conversation.kt | 133 +++++++- .../conversation/ConversationFragment.kt | 62 ---- .../conversation/ConversationUiState.kt | 3 +- .../example/compose/jetchat/data/FakeData.kt | 114 +++++-- .../compose/jetchat/profile/Profile.kt | 124 ++++++- .../jetchat/profile/ProfileFragment.kt | 68 ---- .../jetchat/profile/ProfileViewModel.kt | 8 +- .../jetchat/theme/SharedElementsConfig.kt | 314 ++++++++++++++++++ .../example/compose/jetchat/theme/Themes.kt | 17 +- .../app/src/main/res/layout/content_main.xml | 34 -- .../main/res/navigation/mobile_navigation.xml | 37 --- Jetchat/app/src/main/res/values/themes.xml | 5 +- Jetchat/gradle/libs.versions.toml | 5 + 16 files changed, 778 insertions(+), 317 deletions(-) delete mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt delete mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt create mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/theme/SharedElementsConfig.kt delete mode 100644 Jetchat/app/src/main/res/layout/content_main.xml delete mode 100644 Jetchat/app/src/main/res/navigation/mobile_navigation.xml diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts index 3197802b0b..97e40e6dba 100644 --- a/Jetchat/app/build.gradle.kts +++ b/Jetchat/app/build.gradle.kts @@ -19,6 +19,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose) } @@ -77,7 +78,6 @@ android { buildFeatures { compose = true - viewBinding = true } packaging.resources { @@ -97,23 +97,26 @@ dependencies { implementation(libs.androidx.glance.material3) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) + implementation(libs.android.material3) implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.fragment) - implementation(libs.androidx.navigation.ui.ktx) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.ui.util) - implementation(libs.androidx.compose.ui.viewbinding) implementation(libs.androidx.compose.ui.googlefonts) implementation(libs.androidx.media3.exoplayer) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index ef057174d9..2264ec8b3f 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -20,21 +20,20 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight -import androidx.navigation.NavController -import androidx.navigation.findNavController import androidx.test.espresso.Espresso -import org.junit.Assert.assertEquals +import com.example.compose.jetchat.conversation.ConversationTestTag import org.junit.Rule import org.junit.Test @@ -49,16 +48,13 @@ class NavigationTest { @Test fun app_launches() { // Check app launches at the correct destination - assertEquals(getNavController().currentDestination?.id, R.id.nav_home) + composeTestRule.onNodeWithTag(ConversationTestTag).assertIsDisplayed() } @Test fun profileScreen_back_conversationScreen() { - val navController = getNavController() // Navigate to profile navigateToProfile("Taylor Brooks") - // Check profile is displayed - assertEquals(navController.currentDestination?.id, R.id.nav_profile) // Extra UI check (scroll to Display name as the new profile header is taller) composeTestRule .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) @@ -69,7 +65,24 @@ class NavigationTest { Espresso.pressBack() // Check that we're home - assertEquals(navController.currentDestination?.id, R.id.nav_home) + composeTestRule.onNodeWithTag(ConversationTestTag).assertIsDisplayed() + } + + @Test + fun messageAuthor_click_navigatesToProfile_and_back() { + composeTestRule + .onAllNodes(hasContentDescription("Taylor Brooks")) + .onFirst() + .performClick() + + composeTestRule + .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) + .performScrollTo() + .assertIsDisplayed() + + Espresso.pressBack() + + composeTestRule.onNodeWithTag(ConversationTestTag).assertIsDisplayed() } /** @@ -83,7 +96,7 @@ class NavigationTest { navigateToHomeFromProfile() // Chewie, we're home - assertEquals(getNavController().currentDestination?.id, R.id.nav_home) + composeTestRule.onNodeWithTag(ConversationTestTag).assertIsDisplayed() } private fun navigateToProfile(name: String) { @@ -101,22 +114,10 @@ class NavigationTest { composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu), ) - private fun navigateToHome() { - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.navigation_drawer_open), - ).performClick() - - composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() - } - private fun navigateToHomeFromProfile() { composeTestRule.onAllNodes(isRoot()).onFirst().performTouchInput { swipeRight() } composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() } - - private fun getNavController(): NavController { - return composeTestRule.activity.findNavController(R.id.nav_host_fragment) - } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt index dff10570d9..30a8a5f1aa 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt @@ -20,26 +20,51 @@ import android.os.Bundle import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.material3.DrawerValue.Closed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.viewinterop.AndroidViewBinding -import androidx.core.os.bundleOf +import androidx.compose.ui.unit.IntOffset import androidx.core.view.ViewCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.fragment.NavHostFragment +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.navigation3.ui.NavDisplay import com.example.compose.jetchat.components.JetchatDrawer -import com.example.compose.jetchat.databinding.ContentMainBinding +import com.example.compose.jetchat.conversation.ConversationContent +import com.example.compose.jetchat.data.exampleUiState +import com.example.compose.jetchat.profile.ProfileError +import com.example.compose.jetchat.profile.ProfileScreen +import com.example.compose.jetchat.profile.ProfileViewModel +import com.example.compose.jetchat.theme.LocalNavAnimatedVisibilityScope +import com.example.compose.jetchat.theme.LocalSharedTransitionScope import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +data object ConversationRoute : NavKey + +@Serializable +data class ProfileRoute(val userId: String, val sharedElementKey: String? = null) : NavKey /** * Main activity for the app. @@ -47,7 +72,7 @@ import kotlinx.coroutines.launch class NavActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() - @OptIn(ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -61,6 +86,7 @@ class NavActivity : AppCompatActivity() { val drawerOpen by viewModel.drawerShouldBeOpened .collectAsStateWithLifecycle() + val backStack = rememberNavBackStack(ConversationRoute) var selectedMenu by remember { mutableStateOf("composers") } if (drawerOpen) { // Open drawer and reset state in VM. @@ -80,38 +106,81 @@ class NavActivity : AppCompatActivity() { drawerState = drawerState, selectedMenu = selectedMenu, onChatClicked = { - findNavController().popBackStack(R.id.nav_home, false) + backStack.removeAll { it !is ConversationRoute } scope.launch { drawerState.close() } selectedMenu = it }, onProfileClicked = { - val bundle = bundleOf("userId" to it) - findNavController().navigate(R.id.nav_profile, bundle) + backStack.add(ProfileRoute(userId = it)) scope.launch { drawerState.close() } selectedMenu = it }, ) { - AndroidViewBinding(ContentMainBinding::inflate) + val sharedTransitionScope = LocalSharedTransitionScope.current + val transitionSpec = slideIn(tween(600)) { IntOffset.Zero } togetherWith + slideOut(tween(600)) { IntOffset.Zero } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sharedTransitionScope = sharedTransitionScope, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + transitionSpec = { transitionSpec }, + popTransitionSpec = { transitionSpec }, + entryProvider = entryProvider { + entry { + CompositionLocalProvider( + LocalNavAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current, + ) { + ConversationContent( + uiState = exampleUiState, + navigateToProfile = { user -> + backStack.add(ProfileRoute(userId = user)) + }, + navigateToProfileWithKey = { user, sharedElementKey -> + backStack.add( + ProfileRoute( + userId = user, + sharedElementKey = sharedElementKey, + ), + ) + }, + onNavIconPressed = { + viewModel.openDrawer() + }, + ) + } + } + entry { route -> + CompositionLocalProvider( + LocalNavAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current, + ) { + val profileViewModel: ProfileViewModel = viewModel(key = route.userId) + profileViewModel.setUserId(route.userId) + val userData by profileViewModel.userData.observeAsState() + + if (userData == null) { + ProfileError() + } else { + ProfileScreen( + userData = userData!!, + sharedElementKey = route.sharedElementKey, + ) + } + } + } + }, + ) } } }, ) } - - override fun onSupportNavigateUp(): Boolean { - return findNavController().navigateUp() || super.onSupportNavigateUp() - } - - /** - * See https://issuetracker.google.com/142847973 - */ - private fun findNavController(): NavController { - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - return navHostFragment.navController - } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index be4871aeb6..7b9a35a545 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -20,6 +20,10 @@ package com.example.compose.jetchat.conversation import android.content.ClipDescription import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -59,11 +63,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults @@ -72,6 +78,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -86,6 +93,8 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -99,17 +108,26 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.blur.backdropBlur import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.data.exampleUiState +import com.example.compose.jetchat.theme.FullScreenRoundedRectangle import com.example.compose.jetchat.theme.JetchatTheme +import com.example.compose.jetchat.theme.LocalNavAnimatedVisibilityScope +import com.example.compose.jetchat.theme.SharedElementKey +import com.example.compose.jetchat.theme.sharedAvatarElement +import com.example.compose.jetchat.theme.sharedBoundsRevealWithShapeMorph +import com.example.compose.jetchat.theme.sharedElementTransitionSpec +import com.example.compose.jetchat.theme.toShape import com.example.compose.jetchat.video.FullScreenVideoPlayer import com.example.compose.jetchat.video.VideoThumbnail import kotlinx.coroutines.launch @@ -121,6 +139,7 @@ import kotlinx.coroutines.launch * @param navigateToProfile User action when navigation to a profile is requested * @param modifier [Modifier] to apply to this layout node * @param onNavIconPressed Sends an event up when the user clicks on the menu + * @param navigateToProfileWithKey User action when navigation to a profile is requested with a shared element key */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -129,6 +148,9 @@ fun ConversationContent( navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, onNavIconPressed: () -> Unit = { }, + navigateToProfileWithKey: (user: String, sharedElementKey: String?) -> Unit = { user, _ -> + navigateToProfile(user) + }, ) { val authorMe = stringResource(R.string.author_me) val timeNow = stringResource(id = R.string.now) @@ -224,6 +246,7 @@ fun ConversationContent( Messages( messages = uiState.messages, navigateToProfile = navigateToProfile, + navigateToProfileWithKey = navigateToProfileWithKey, modifier = Modifier.weight(1f), scrollState = scrollState, contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()), @@ -303,8 +326,10 @@ fun ChannelNameBar( onNavIconPressed = onNavIconPressed, navigationIcon = {}, title = { + val navDrawerDesc = stringResource(id = R.string.navigation_drawer_open) Surface( onClick = onNavIconPressed, + modifier = Modifier.semantics { contentDescription = navDrawerDesc }, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f), ) { @@ -398,6 +423,9 @@ fun Messages( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), onVideoClick: (String) -> Unit = {}, + navigateToProfileWithKey: (user: String, sharedElementKey: String?) -> Unit = { user, _ -> + navigateToProfile(user) + }, ) { val scope = rememberCoroutineScope() Box(modifier = modifier) { @@ -431,7 +459,7 @@ fun Messages( item(key = content.id, contentType = "message") { Message( - onAuthorClick = { name -> navigateToProfile(name) }, + onAuthorClick = { name -> navigateToProfileWithKey(name, content.id) }, msg = content, isUserMe = content.author == authorMe, isFirstMessageByAuthor = isFirstMessageByAuthor, @@ -489,6 +517,7 @@ fun Message( if (!isUserMe) { if (isLastMessageByAuthor) { AuthorAvatar( + sharedElementKey = msg.id, authorImage = msg.authorImage, authorName = msg.author, borderColor = borderColor, @@ -515,6 +544,7 @@ fun Message( if (isUserMe) { if (isLastMessageByAuthor) { AuthorAvatar( + sharedElementKey = msg.id, authorImage = msg.authorImage, authorName = msg.author, borderColor = borderColor, @@ -527,21 +557,100 @@ fun Message( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) @Composable -private fun RowScope.AuthorAvatar(authorImage: Int, authorName: String, borderColor: Color, onAuthorClick: (String) -> Unit) { - Image( +private fun RowScope.AuthorAvatar( + sharedElementKey: String, + authorImage: Int, + authorName: String, + borderColor: Color, + onAuthorClick: (String) -> Unit, +) { + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + val progress = if (animatedVisibilityScope != null) { + val animatedProgress by animatedVisibilityScope.transition.animateFloat( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + label = "avatarShapeProgress", + ) { state -> + when (state) { + EnterExitState.PreEnter -> 1f + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 1f + } + } + animatedProgress + } else { + 0f + } + + val progressiveBlurRadius = if (animatedVisibilityScope != null) { + val animatedRadius by animatedVisibilityScope.transition.animateDp( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + label = "avatarProgressiveBlur", + ) { state -> + when (state) { + EnterExitState.PreEnter -> 32.dp + EnterExitState.Visible -> 0.dp + EnterExitState.PostExit -> 32.dp + } + } + animatedRadius + } else { + 0.dp + } + + val morph = remember { + Morph(MaterialShapes.Cookie9Sided, FullScreenRoundedRectangle) + } + val currentShape = morph.toShape(progress) + val borderAlpha = (1f - progress).coerceIn(0f, 1f) + + Box( modifier = Modifier - .clickable(onClick = { onAuthorClick(authorName) }) + .align(Alignment.Top) .padding(horizontal = 16.dp) + .sharedBoundsRevealWithShapeMorph( + sharedElementKey = SharedElementKey.ProfileContainer(sharedElementKey), + restingShape = MaterialShapes.Cookie9Sided, + targetShape = FullScreenRoundedRectangle, + renderInOverlayDuringTransition = false, + targetValueByState = { + when (it) { + EnterExitState.PreEnter -> 1f + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 1f + } + }, + keepChildrenSizePlacement = false, + ) .size(42.dp) - .border(1.5.dp, borderColor, CircleShape) - .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) - .clip(CircleShape) - .align(Alignment.Top), - painter = painterResource(id = authorImage), - contentScale = ContentScale.Crop, - contentDescription = null, - ) + .clickable(onClick = { onAuthorClick(authorName) }), + ) { + Image( + modifier = Modifier + .fillMaxSize() + .sharedAvatarElement( + sharedElementKey = SharedElementKey.ProfileAvatar(sharedElementKey), + restingShape = MaterialShapes.Cookie9Sided, + targetShape = FullScreenRoundedRectangle, + ) + .border(1.5.dp, borderColor.copy(alpha = borderAlpha), currentShape) + .border(3.dp, MaterialTheme.colorScheme.surface.copy(alpha = borderAlpha), currentShape) + .clip(currentShape) + .blur { + radius = BlurRadiusSpec.verticalGradient( + listOf( + BlurStop(0.5f, 0.dp), + BlurStop(1.0f, progressiveBlurRadius), + ), + ) + edgeTreatment = BlurredEdgeTreatment.Unbounded + }, + painter = painterResource(id = authorImage), + contentScale = ContentScale.Crop, + contentDescription = authorName, + ) + } } @Composable diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt deleted file mode 100644 index dfb1ffff9f..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.conversation - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.compose.ui.platform.ComposeView -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import com.example.compose.jetchat.MainViewModel -import com.example.compose.jetchat.R -import com.example.compose.jetchat.data.exampleUiState -import com.example.compose.jetchat.theme.JetchatTheme - -class ConversationFragment : Fragment() { - - private val activityViewModel: MainViewModel by activityViewModels() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - ComposeView(inflater.context).apply { - layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) - - setContent { - JetchatTheme { - ConversationContent( - uiState = exampleUiState, - navigateToProfile = { user -> - // Click callback - val bundle = bundleOf("userId" to user) - findNavController().navigate( - R.id.nav_profile, - bundle, - ) - }, - onNavIconPressed = { - activityViewModel.openDrawer() - }, - ) - } - } - } -} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt index d53ad941c1..1fb6cac54c 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -19,6 +19,7 @@ package com.example.compose.jetchat.conversation import androidx.compose.runtime.Immutable import androidx.compose.runtime.toMutableStateList import com.example.compose.jetchat.R +import com.example.compose.jetchat.data.getAuthorImage class ConversationUiState(val channelName: String, val channelMembers: Int, initialMessages: List) { private val _messages: MutableList = initialMessages.toMutableStateList() @@ -35,7 +36,7 @@ data class Message( val content: String, val timestamp: String, val image: Int? = null, - val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, + val authorImage: Int = getAuthorImage(author), val videoUri: String? = null, val id: String = "$author-$timestamp-$content", ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index bfb587d029..d9c83eb68c 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -26,6 +26,90 @@ import com.example.compose.jetchat.data.EMOJIS.EMOJI_PINK_HEART import com.example.compose.jetchat.data.EMOJIS.EMOJI_POINTS import com.example.compose.jetchat.profile.ProfileScreenState +/** + * Example "me" profile. + */ +val meProfile = ProfileScreenState( + userId = "me", + photo = R.drawable.ali, + name = "Ali Conors", + status = "Online", + displayName = "aliconors", + position = "Senior Android Dev at Yearin\nGoogle Developer Expert", + twitter = "twitter.com/aliconors", + timeZone = "In your timezone", + commonChannels = null, +) + +/** + * Example colleague profile (Taylor Brooks) + */ +val colleagueProfile = ProfileScreenState( + userId = "12345", + photo = R.drawable.someone_else, + name = "Taylor Brooks", + status = "Away", + displayName = "taylor", + position = "Senior Android Dev at Openlane", + twitter = "twitter.com/taylorbrookscodes", + timeZone = "12:25 AM local time (Eastern Daylight Time)", + commonChannels = "2", +) + +val taylorProfile = colleagueProfile + +/** + * Example John Glenn profile. + */ +val johnGlennProfile = ProfileScreenState( + userId = "john_glenn", + photo = R.drawable.android_profile_pic, + name = "John Glenn", + status = "Online", + displayName = "jglenn", + position = "Staff Android Engineer at SpaceApps", + twitter = "twitter.com/johnglenn", + timeZone = "11:25 PM local time (Central Daylight Time)", + commonChannels = "3", +) + +/** + * Example Shangeeth Sivan profile. + */ +val shangeethProfile = ProfileScreenState( + userId = "shangeeth", + photo = R.drawable.someone_else, + name = "Shangeeth Sivan", + status = "Online", + displayName = "shangeeth", + position = "Android Dev & Glance Widgets Specialist", + twitter = "twitter.com/shangeethsivan", + timeZone = "6:25 AM local time (India Standard Time)", + commonChannels = "2", +) + +val allProfiles: List = listOf( + meProfile, + taylorProfile, + johnGlennProfile, + shangeethProfile, +) + +fun getProfile(userIdOrName: String?): ProfileScreenState { + if (userIdOrName == null) return meProfile + return allProfiles.firstOrNull { profile -> + profile.userId.equals(userIdOrName, ignoreCase = true) || + profile.displayName.equals(userIdOrName, ignoreCase = true) || + profile.name.equals(userIdOrName, ignoreCase = true) + } ?: colleagueProfile +} + +fun getAuthorImage(author: String): Int = when (author) { + "me", "Ali Conors", "aliconors" -> R.drawable.ali + "John Glenn", "jglenn" -> R.drawable.android_profile_pic + else -> R.drawable.someone_else +} + val initialMessages = listOf( Message( "me", @@ -244,36 +328,6 @@ val exampleUiState = ConversationUiState( channelMembers = 42, ) -/** - * Example colleague profile - */ -val colleagueProfile = ProfileScreenState( - userId = "12345", - photo = R.drawable.android_profile_pic, - name = "Taylor Brooks", - status = "Away", - displayName = "taylor", - position = "Senior Android Dev at Openlane", - twitter = "twitter.com/taylorbrookscodes", - timeZone = "12:25 AM local time (Eastern Daylight Time)", - commonChannels = "2", -) - -/** - * Example "me" profile. - */ -val meProfile = ProfileScreenState( - userId = "me", - photo = R.drawable.android_profile_pic, - name = "Ali Conors", - status = "Online", - displayName = "aliconors", - position = "Senior Android Dev at Yearin\nGoogle Developer Expert", - twitter = "twitter.com/aliconors", - timeZone = "In your timezone", - commonChannels = null, -) - object EMOJIS { // EMOJI 15 const val EMOJI_PINK_HEART = "\uD83E\uDE77" diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt index 853de85ab4..8bac07d273 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt @@ -16,11 +16,16 @@ package com.example.compose.jetchat.profile +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -29,68 +34,137 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.blur.BlurRadiusSpec import androidx.compose.ui.graphics.blur.BlurStop +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R +import com.example.compose.jetchat.blur.backdropBlur import com.example.compose.jetchat.components.AnimatingFabContent import com.example.compose.jetchat.components.baselineHeight import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile +import com.example.compose.jetchat.theme.FullScreenRoundedRectangle import com.example.compose.jetchat.theme.JetchatTheme +import com.example.compose.jetchat.theme.LocalNavAnimatedVisibilityScope +import com.example.compose.jetchat.theme.SharedElementKey +import com.example.compose.jetchat.theme.sharedAvatarElement +import com.example.compose.jetchat.theme.sharedBoundsRevealWithShapeMorph +import com.example.compose.jetchat.theme.sharedElementTransitionSpec +import com.example.compose.jetchat.theme.toShape +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) @Composable -fun ProfileScreen(userData: ProfileScreenState) { +fun ProfileScreen(userData: ProfileScreenState, sharedElementKey: String? = null) { var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } if (functionalityNotAvailablePopupShown) { FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } } val scrollState = rememberScrollState() + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + val blurRadius = if (animatedVisibilityScope != null) { + val animatedBlur by animatedVisibilityScope.transition.animateDp( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + label = "profileBackdropBlur", + ) { state -> + when (state) { + EnterExitState.PreEnter -> 0.dp + EnterExitState.Visible -> 32.dp + EnterExitState.PostExit -> 0.dp + } + } + animatedBlur + } else { + 0.dp + } + + val contentAlpha = if (animatedVisibilityScope != null) { + val animatedAlpha by animatedVisibilityScope.transition.animateFloat( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + label = "profileContentAlpha", + ) { state -> + when (state) { + EnterExitState.PreEnter -> 0f + EnterExitState.Visible -> 1f + EnterExitState.PostExit -> 0f + } + } + animatedAlpha + } else { + 1f + } Box( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .backdropBlur(radius = blurRadius), ) { - Surface { + Surface( + modifier = Modifier + .fillMaxSize() + .sharedBoundsRevealWithShapeMorph( + sharedElementKey = sharedElementKey?.let { SharedElementKey.ProfileContainer(it) }, + restingShape = FullScreenRoundedRectangle, + targetShape = MaterialShapes.Cookie9Sided, + zIndexInOverlay = 1f, + targetValueByState = { + when (it) { + EnterExitState.PreEnter -> 1f + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 1f + } + }, + keepChildrenSizePlacement = true, + ), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = contentAlpha), + ) { Column( modifier = Modifier .fillMaxSize() - .background(color = MaterialTheme.colorScheme.surfaceContainer) .verticalScroll(scrollState), ) { - ProfileHeader(userData) - UserInfoFields(userData) + ProfileHeader( + data = userData, + sharedElementKey = sharedElementKey, + ) + UserInfoFields( + userData = userData, + modifier = Modifier.graphicsLayer { this.alpha = contentAlpha }, + ) } } } } @Composable -private fun UserInfoFields(userData: ProfileScreenState) { - Column(modifier = Modifier.navigationBarsPadding()) { +private fun UserInfoFields(userData: ProfileScreenState, modifier: Modifier = Modifier) { + Column(modifier = modifier.navigationBarsPadding()) { Spacer(modifier = Modifier.height(40.dp)) NameAndPosition(userData) @@ -143,23 +217,47 @@ private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier ) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) @Composable -private fun ProfileHeader(data: ProfileScreenState) { +private fun ProfileHeader(data: ProfileScreenState, sharedElementKey: String? = null) { + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + val progressiveBlurRadius = if (animatedVisibilityScope != null) { + val animatedRadius by animatedVisibilityScope.transition.animateDp( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + label = "profileProgressiveBlur", + ) { state -> + when (state) { + EnterExitState.PreEnter -> 0.dp + EnterExitState.Visible -> 32.dp + EnterExitState.PostExit -> 0.dp + } + } + animatedRadius + } else { + 32.dp + } + data.photo?.let { Image( modifier = Modifier .fillMaxWidth() + .aspectRatio(1f) + .sharedAvatarElement( + sharedElementKey = sharedElementKey?.let { key -> SharedElementKey.ProfileAvatar(key) }, + restingShape = FullScreenRoundedRectangle, + targetShape = MaterialShapes.Cookie9Sided, + ) .blur { radius = BlurRadiusSpec.verticalGradient( listOf( BlurStop(0.5f, 0.dp), - BlurStop(1.0f, 32.dp), + BlurStop(1.0f, progressiveBlurRadius), ), ) edgeTreatment = BlurredEdgeTreatment.Unbounded }, painter = painterResource(id = it), - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, contentDescription = null, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt deleted file mode 100644 index 8af0b5f957..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.profile - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import com.example.compose.jetchat.MainViewModel -import com.example.compose.jetchat.theme.JetchatTheme - -class ProfileFragment : Fragment() { - - private val viewModel: ProfileViewModel by viewModels() - private val activityViewModel: MainViewModel by activityViewModels() - - override fun onAttach(context: Context) { - super.onAttach(context) - // Consider using safe args plugin - val userId = arguments?.getString("userId") - viewModel.setUserId(userId) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - ComposeView(inflater.context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - - setContent { - val userData by viewModel.userData.observeAsState() - - JetchatTheme { - if (userData == null) { - ProfileError() - } else { - ProfileScreen( - userData = userData!!, - ) - } - } - } - } -} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt index 9b2d028094..0201f7a27f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.example.compose.jetchat.data.colleagueProfile +import com.example.compose.jetchat.data.getProfile import com.example.compose.jetchat.data.meProfile class ProfileViewModel : ViewModel() { @@ -32,12 +33,7 @@ class ProfileViewModel : ViewModel() { if (newUserId != userId) { userId = newUserId ?: meProfile.userId } - // Workaround for simplicity - _userData.value = if (userId == meProfile.userId || userId == meProfile.displayName) { - meProfile - } else { - colleagueProfile - } + _userData.value = getProfile(userId) } private val _userData = MutableLiveData() diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/SharedElementsConfig.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/SharedElementsConfig.kt new file mode 100644 index 0000000000..93698b4867 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/SharedElementsConfig.kt @@ -0,0 +1,314 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) + +package com.example.compose.jetchat.theme + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.toPath +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.graphics.shapes.CornerRounding +import androidx.graphics.shapes.Morph +import androidx.graphics.shapes.RoundedPolygon +import androidx.graphics.shapes.rectangle +import androidx.navigation3.ui.LocalNavAnimatedContentScope + +sealed interface SharedElementKey { + data class ProfileAvatar(val key: String) : SharedElementKey + data class ProfileContainer(val key: String) : SharedElementKey +} + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { + null +} + +val LocalNavAnimatedVisibilityScope = compositionLocalOf { + null +} + +val FullScreenRoundedRectangle: RoundedPolygon = RoundedPolygon.rectangle( + perVertexRounding = listOf( + CornerRounding.Unrounded, + CornerRounding.Unrounded, + CornerRounding(0.15f), + CornerRounding(0.15f), + ), +).normalized() + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun Morph.toShape(progress: Float, startAngle: Int = 0): Shape { + return object : Shape { + private var workPath: Path? = null + private var lastSize = Size.Unspecified + + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + if (size != lastSize || workPath == null) { + lastSize = size + workPath = Path() + } else { + workPath!!.rewind() + } + val path = workPath!! + toPath(progress = progress, path = path, startAngle = startAngle) + val scaleMatrix = Matrix().apply { scale(x = size.width, y = size.height) } + path.transform(scaleMatrix) + path.translate(size.center - path.getBounds().center) + return Outline.Generic(path) + } + } +} + +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalSharedTransitionApi::class, +) +val MotionScheme.sharedElementTransitionBounds: BoundsTransform + @Composable + get() = BoundsTransform { _, _ -> sharedElementTransitionSpec() } + +fun MotionScheme.sharedElementTransitionSpec(): FiniteAnimationSpec { + return tween(600) +} + +class MorphOverlayClip(val morph: Morph, private val animatedProgress: () -> Float) : SharedTransitionScope.OverlayClip { + private val matrix = Matrix() + private val workPath = Path() + + override fun getClipPath( + sharedContentState: SharedTransitionScope.SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density, + ): Path? { + val progress = animatedProgress.invoke() + workPath.reset() + morph.toPath(progress = progress, path = workPath) + matrix.reset() + matrix.scale(bounds.width, bounds.height) + workPath.transform(matrix) + workPath.translate(bounds.center - workPath.getBounds().center) + return workPath + } +} + +@Composable +fun Modifier.sharedBoundsRevealWithShapeMorph( + sharedElementKey: Any?, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, + boundsTransform: BoundsTransform = MaterialTheme.motionScheme.sharedElementTransitionBounds, + resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + restingShape: RoundedPolygon = FullScreenRoundedRectangle, + targetShape: RoundedPolygon = MaterialShapes.Cookie9Sided, + renderInOverlayDuringTransition: Boolean = true, + zIndexInOverlay: Float = 0f, + targetValueByState: @Composable (state: EnterExitState) -> Float = { + when (it) { + EnterExitState.PreEnter -> 1f + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 1f + } + }, + keepChildrenSizePlacement: Boolean = true, +): Modifier { + if (sharedElementKey == null || sharedTransitionScope == null || animatedVisibilityScope == null) { + return this + } + with(sharedTransitionScope) { + return this@sharedBoundsRevealWithShapeMorph.sharedBoundsRevealWithShapeMorph( + sharedContentState = rememberSharedContentState(sharedElementKey), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = boundsTransform, + resizeMode = resizeMode, + restingShape = restingShape, + targetShape = targetShape, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + targetValueByState = targetValueByState, + keepChildrenSizePlacement = keepChildrenSizePlacement, + ) + } +} + +@Composable +fun Modifier.sharedBoundsRevealWithShapeMorph( + sharedContentState: SharedTransitionScope.SharedContentState, + sharedTransitionScope: SharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No SharedTransitionScope provided"), + animatedVisibilityScope: AnimatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: LocalNavAnimatedContentScope.current, + boundsTransform: BoundsTransform = MaterialTheme.motionScheme.sharedElementTransitionBounds, + resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + restingShape: RoundedPolygon = FullScreenRoundedRectangle, + targetShape: RoundedPolygon = MaterialShapes.Cookie9Sided, + renderInOverlayDuringTransition: Boolean = true, + zIndexInOverlay: Float = 0f, + targetValueByState: @Composable (state: EnterExitState) -> Float = { + when (it) { + EnterExitState.PreEnter -> 1f + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 1f + } + }, + keepChildrenSizePlacement: Boolean = true, +): Modifier { + with(sharedTransitionScope) { + val animatedProgress = animatedVisibilityScope.transition.animateFloat( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + targetValueByState = targetValueByState, + ) + + val morph = remember(restingShape, targetShape) { + Morph(restingShape, targetShape) + } + val morphClip = remember(morph) { + MorphOverlayClip(morph) { animatedProgress.value } + } + val modifier = Modifier + .drawWithContent { + animatedProgress.value + drawContent() + } + .then( + if (keepChildrenSizePlacement) { + Modifier + .skipToLookaheadSize() + .skipToLookaheadPosition() + } else { + Modifier + }, + ) + return this@sharedBoundsRevealWithShapeMorph + .sharedBounds( + sharedContentState = sharedContentState, + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = boundsTransform, + resizeMode = resizeMode, + clipInOverlayDuringTransition = morphClip, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + ) + .then(modifier) + } +} + +@Composable +fun Modifier.sharedAvatarElement( + sharedElementKey: Any?, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalNavAnimatedVisibilityScope.current, + boundsTransform: BoundsTransform = MaterialTheme.motionScheme.sharedElementTransitionBounds, + zIndexInOverlay: Float = 2f, + restingShape: RoundedPolygon? = null, + targetShape: RoundedPolygon? = null, + targetValueByState: @Composable (state: EnterExitState) -> Float = { + when (it) { + EnterExitState.PreEnter -> 1f + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 1f + } + }, +): Modifier { + if (sharedElementKey == null || sharedTransitionScope == null || animatedVisibilityScope == null) { + return this + } + with(sharedTransitionScope) { + val (morphClip, drawModifier) = if (restingShape != null && targetShape != null) { + val animatedProgress = animatedVisibilityScope.transition.animateFloat( + transitionSpec = { MaterialTheme.motionScheme.sharedElementTransitionSpec() }, + targetValueByState = targetValueByState, + ) + val morph = remember(restingShape, targetShape) { + Morph(restingShape, targetShape) + } + val clip = remember(morph) { + MorphOverlayClip(morph) { animatedProgress.value } + } + val modifier = Modifier.drawWithContent { + animatedProgress.value + drawContent() + } + clip to modifier + } else { + null to Modifier + } + + return if (morphClip != null) { + this@sharedAvatarElement + .then(drawModifier) + .sharedElement( + sharedContentState = rememberSharedContentState(sharedElementKey), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = boundsTransform, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = morphClip, + ) + } else { + this@sharedAvatarElement.sharedElement( + sharedContentState = rememberSharedContentState(sharedElementKey), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = boundsTransform, + zIndexInOverlay = zIndexInOverlay, + ) + } + } +} + +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Composable +fun SharedElementContextPreview(content: @Composable () -> Unit) { + JetchatTheme { + AnimatedContent(targetState = Unit) { + CompositionLocalProvider( + LocalNavAnimatedContentScope provides this, + LocalNavAnimatedVisibilityScope provides this, + ) { + content() + } + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt index f77be35996..00a6ae0802 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt @@ -18,6 +18,8 @@ package com.example.compose.jetchat.theme import android.annotation.SuppressLint import android.os.Build +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme @@ -25,6 +27,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -87,6 +90,7 @@ val JetchatLightColorScheme = lightColorScheme( outline = BlueGrey50, ) +@OptIn(ExperimentalSharedTransitionApi::class) @SuppressLint("NewApi") @Composable fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) { @@ -108,6 +112,17 @@ fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: B MaterialTheme( colorScheme = myColorScheme, typography = JetchatTypography, - content = content, + content = { + val existingScope = LocalSharedTransitionScope.current + if (existingScope != null) { + content() + } else { + SharedTransitionLayout { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + content() + } + } + } + }, ) } diff --git a/Jetchat/app/src/main/res/layout/content_main.xml b/Jetchat/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 31302894b9..0000000000 --- a/Jetchat/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/Jetchat/app/src/main/res/navigation/mobile_navigation.xml b/Jetchat/app/src/main/res/navigation/mobile_navigation.xml deleted file mode 100644 index 3db49d9468..0000000000 --- a/Jetchat/app/src/main/res/navigation/mobile_navigation.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - diff --git a/Jetchat/app/src/main/res/values/themes.xml b/Jetchat/app/src/main/res/values/themes.xml index 0e815ceaa4..f5aa3da7bf 100644 --- a/Jetchat/app/src/main/res/values/themes.xml +++ b/Jetchat/app/src/main/res/values/themes.xml @@ -35,8 +35,5 @@ true -