feat(app): implement shared transition for search functionality in HomeTopBar and HomeSearchFullScreen

This commit is contained in:
2026-05-02 13:06:21 +02:00
parent 44219d00d8
commit 64b1032b74
5 changed files with 131 additions and 44 deletions

View File

@@ -6,6 +6,8 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -41,6 +43,7 @@ import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.navigation.NavigationCommand import hu.bbara.purefin.navigation.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.Route
@@ -131,6 +134,7 @@ class PurefinActivity : ComponentActivity() {
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
} }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun MainApp( fun MainApp(
userSessionRepository: UserSessionRepository, userSessionRepository: UserSessionRepository,
@@ -172,9 +176,11 @@ class PurefinActivity : ComponentActivity() {
} }
} }
SharedTransitionLayout {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavigationManager provides navigationManager, LocalNavigationManager provides navigationManager,
LocalNavigationBackStack provides backStack.toList() LocalNavigationBackStack provides backStack.toList(),
LocalSharedTransitionScope provides this
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
@@ -212,6 +218,7 @@ class PurefinActivity : ComponentActivity() {
entryProvider = appEntryProvider entryProvider = appEntryProvider
) )
} }
}
} else { } else {
LoginScreen() LoginScreen()
} }

View File

@@ -1,5 +1,8 @@
package hu.bbara.purefin.navigation package hu.bbara.purefin.navigation
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
@@ -8,3 +11,11 @@ val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") } staticCompositionLocalOf { error("NavigationManager not provided") }
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() } val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
val LocalNavSharedAnimatedVisibilityScope =
compositionLocalOf<AnimatedVisibilityScope?> { null }
const val HOME_SEARCH_SHARED_BOUNDS_KEY = "home_search_shared_bounds"

View File

@@ -1,6 +1,8 @@
package hu.bbara.purefin.navigation package hu.bbara.purefin.navigation
import androidx.compose.runtime.CompositionLocalProvider
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import hu.bbara.purefin.ui.screen.library.LibraryScreen import hu.bbara.purefin.ui.screen.library.LibraryScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.AppScreen import hu.bbara.purefin.ui.screen.AppScreen
@@ -11,11 +13,19 @@ import hu.bbara.purefin.ui.screen.series.SeriesScreen
fun EntryProviderScope<Route>.appRouteEntryBuilder() { fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Home> { entry<Route.Home> {
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
AppScreen() AppScreen()
} }
}
entry<Route.HomeSearchRoute> { entry<Route.HomeSearchRoute> {
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
HomeSearchFullScreen() HomeSearchFullScreen()
} }
}
entry<Route.MovieRoute> { entry<Route.MovieRoute> {
MovieScreen(movie = it.item) MovieScreen(movie = it.item)
} }

View File

@@ -1,5 +1,9 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -21,7 +25,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
@@ -30,11 +38,34 @@ fun HomeTopBar(
onLogoutClick: () -> Unit, onLogoutClick: () -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
var isProfileMenuExpanded by remember { mutableStateOf(false) } var isProfileMenuExpanded by remember { mutableStateOf(false) }
DefaultTopBar() DefaultTopBar()
{ {
val searchButtonModifier = if (
sharedTransitionScope != null &&
animatedVisibilityScope != null
) {
with(sharedTransitionScope) {
Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(
key = HOME_SEARCH_SHARED_BOUNDS_KEY
),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
)
.size(50.dp)
}
} else {
Modifier.size(50.dp)
}
IconButton( IconButton(
onClick = onSearchClick, onClick = onSearchClick,
colors = IconButtonColors( colors = IconButtonColors(
@@ -43,7 +74,7 @@ fun HomeTopBar(
disabledContainerColor = scheme.surface, disabledContainerColor = scheme.surface,
disabledContentColor = scheme.onSurface disabledContentColor = scheme.onSurface
), ),
modifier = Modifier.size(50.dp), modifier = searchButtonModifier,
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Search, imageVector = Icons.Outlined.Search,

View File

@@ -1,5 +1,9 @@
package hu.bbara.purefin.ui.screen.home.components.search package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -51,17 +55,41 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.image.PurefinLogo import hu.bbara.purefin.ui.common.image.PurefinLogo
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun HomeSearchFullScreen( fun HomeSearchFullScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
val sharedBoundsModifier = if (
sharedTransitionScope != null &&
animatedVisibilityScope != null
) {
with(sharedTransitionScope) {
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(
key = HOME_SEARCH_SHARED_BOUNDS_KEY
),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
)
}
} else {
Modifier
}
Column( Column(
modifier = modifier modifier = modifier
.then(sharedBoundsModifier)
.fillMaxSize() .fillMaxSize()
.background(scheme.background) .background(scheme.background)
.statusBarsPadding() .statusBarsPadding()