Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add shared element to Jetcaster #1300

Closed
wants to merge 9 commits into from
6 changes: 6 additions & 0 deletions Jetcaster/app/build.gradle.kts
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

/*
* Copyright 2020 The Android Open Source Project
*
Expand Down Expand Up @@ -81,6 +83,9 @@ android {
excludes += "/META-INF/AL2.0"
excludes += "/META-INF/LGPL2.1"
}
tasks.withType(KotlinCompile::class.java) {
kotlinOptions.freeCompilerArgs += "-Xcontext-receivers"
}
}

dependencies {
Expand All @@ -106,6 +111,7 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.window)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
Expand Down
1 change: 1 addition & 0 deletions Jetcaster/app/src/main/AndroidManifest.xml
Expand Up @@ -28,6 +28,7 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Jetcaster"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true">

<activity
Expand Down
173 changes: 114 additions & 59 deletions Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt
Expand Up @@ -14,24 +14,43 @@
* limitations under the License.
*/

@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalSharedTransitionApi::class)

package com.example.jetcaster.ui

import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDeepLink
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.get
import androidx.window.layout.DisplayFeature
import com.example.jetcaster.R
import com.example.jetcaster.core.data.di.Graph.episodePlayer
Expand All @@ -43,78 +62,114 @@ import com.example.jetcaster.ui.player.PlayerViewModel
import com.example.jetcaster.ui.podcast.PodcastDetailsScreen
import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel

val LocalSharedElementScopes = compositionLocalOf { SharedElementScopes() }
data class SharedElementScopes(val scope: SharedTransitionScope? = null,
val animatedVisibilityScope: AnimatedVisibilityScope? = null)
@Composable
fun JetcasterApp(
windowSizeClass: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
appState: JetcasterAppState = rememberJetcasterAppState()
) {
if (appState.isOnline) {
NavHost(
navController = appState.navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) { backStackEntry ->
MainScreen(
windowSizeClass = windowSizeClass,
navigateToPlayer = { episode ->
appState.navigateToPlayer(episode.uri, backStackEntry)
SharedTransitionLayout {
NavHost(
navController = appState.navController,
startDestination = Screen.Home.route,

) {
fun NavGraphBuilder.sharedElementComposable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
exitTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
popEnterTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
enterTransition,
popExitTransition: (@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
exitTransition,
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
composable(route, arguments, deepLinks, enterTransition, exitTransition, popEnterTransition, popExitTransition
) {
CompositionLocalProvider(
LocalSharedElementScopes provides SharedElementScopes(
this@SharedTransitionLayout,
this@composable
)
) {
content(it)
}
}
)
}
composable(Screen.Player.route) { backStackEntry ->
val playerViewModel: PlayerViewModel = viewModel(
factory = PlayerViewModel.provideFactory(
owner = backStackEntry,
defaultArgs = backStackEntry.arguments
)
)
PlayerScreen(
windowSizeClass,
displayFeatures,
playerViewModel,
onBackPress = appState::navigateBack
)
}
composable(
route = Screen.PodcastDetails.route,
enterTransition = {
fadeIn(
animationSpec = tween(
300, easing = LinearEasing
}

sharedElementComposable(Screen.Home.route) { backStackEntry ->
MainScreen(
windowSizeClass = windowSizeClass,
navigateToPlayer = { episode ->
appState.navigateToPlayer(episode.uri, backStackEntry)
}
)
) + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
300, easing = LinearEasing
}
sharedElementComposable(Screen.Player.route) { backStackEntry ->
val playerViewModel: PlayerViewModel = viewModel(
factory = PlayerViewModel.provideFactory(
owner = backStackEntry,
defaultArgs = backStackEntry.arguments
)
) + slideOutOfContainer(
animationSpec = tween(300, easing = EaseOut),
towards = AnimatedContentTransitionScope.SlideDirection.End
)
}
) { backStackEntry ->
val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel(
factory = PodcastDetailsViewModel.provideFactory(
episodeStore = episodeStore,
podcastStore = podcastStore,
episodePlayer = episodePlayer,
owner = backStackEntry,
defaultArgs = backStackEntry.arguments

PlayerScreen(
windowSizeClass,
displayFeatures,
playerViewModel,
onBackPress = appState::navigateBack
)
)
PodcastDetailsScreen(
viewModel = podcastDetailsViewModel,
navigateToPlayer = { episodePlayer ->
appState.navigateToPlayer(episodePlayer.uri, backStackEntry)
}
sharedElementComposable(
route = Screen.PodcastDetails.route,
enterTransition = {
fadeIn(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
)
},
navigateBack = appState::navigateBack
)
exitTransition = {
fadeOut(
animationSpec = tween(
300, easing = LinearEasing
)
) + slideOutOfContainer(
animationSpec = tween(300, easing = EaseOut),
towards = AnimatedContentTransitionScope.SlideDirection.End
)
}
) { backStackEntry ->
val podcastDetailsViewModel: PodcastDetailsViewModel = viewModel(
factory = PodcastDetailsViewModel.provideFactory(
episodeStore = episodeStore,
podcastStore = podcastStore,
episodePlayer = episodePlayer,
owner = backStackEntry,
defaultArgs = backStackEntry.arguments
)
)
PodcastDetailsScreen(
viewModel = podcastDetailsViewModel,
navigateToPlayer = { episodePlayer ->
appState.navigateToPlayer(episodePlayer.uri, backStackEntry)
},
navigateBack = appState::navigateBack
)
}
}
}
} else {
Expand Down
Expand Up @@ -42,14 +42,15 @@ sealed class Screen(val route: String) {
fun createRoute(episodeUri: String) = "player/$episodeUri"
}

object PodcastDetails : Screen("podcast/{$ARG_PODCAST_URI}") {
object PodcastDetails : Screen("podcast/{$ARG_PODCAST_URI}?image={$ARG_PODCAST_IMAGE}") {

val PODCAST_URI = "podcastUri"
fun createRoute(podcastUri: String) = "podcast/$podcastUri"
fun createRoute(podcastUri: String, imageUrl: String) = "podcast/$podcastUri?image=$imageUrl"
}

companion object {
val ARG_PODCAST_URI = "podcastUri"
val ARG_PODCAST_IMAGE = "podcastImage"
val ARG_EPISODE_URI = "episodeUri"
}
}
Expand Down Expand Up @@ -81,10 +82,13 @@ class JetcasterAppState(
}
}

fun navigateToPodcastDetails(podcastUri: String, from: NavBackStackEntry) {
fun navigateToPodcastDetails(podcastUri: String,
imageUrl: String,
from: NavBackStackEntry) {
if (from.lifecycleIsResumed()) {
val encodedUri = Uri.encode(podcastUri)
navController.navigate(Screen.PodcastDetails.createRoute(encodedUri))
val encodedImageUrl = Uri.encode(imageUrl)
navController.navigate(Screen.PodcastDetails.createRoute(encodedUri, encodedImageUrl))
}
}

Expand Down