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.enableEdgeToEdge
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.fadeOut
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.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.navigation.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
@@ -131,6 +134,7 @@ class PurefinActivity : ComponentActivity() {
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MainApp(
userSessionRepository: UserSessionRepository,
@@ -172,45 +176,48 @@ class PurefinActivity : ComponentActivity() {
}
}
CompositionLocalProvider(
LocalNavigationManager provides navigationManager,
LocalNavigationBackStack provides backStack.toList()
) {
NavDisplay(
backStack = backStack,
onBack = { navigationManager.pop() },
modifier = Modifier.fillMaxSize().background(backgroundDark),
transitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 140
)
) togetherWith
fadeOut(animationSpec = tween(durationMillis = 140))
},
popTransitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 140
)
) togetherWith
fadeOut(animationSpec = tween(durationMillis = 140))
},
predictivePopTransitionSpec = { _ ->
fadeIn(
animationSpec = tween(
durationMillis = 140
)
) togetherWith
fadeOut(animationSpec = tween(durationMillis = 140))
},
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = appEntryProvider
)
SharedTransitionLayout {
CompositionLocalProvider(
LocalNavigationManager provides navigationManager,
LocalNavigationBackStack provides backStack.toList(),
LocalSharedTransitionScope provides this
) {
NavDisplay(
backStack = backStack,
onBack = { navigationManager.pop() },
modifier = Modifier.fillMaxSize().background(backgroundDark),
transitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 140
)
) togetherWith
fadeOut(animationSpec = tween(durationMillis = 140))
},
popTransitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 140
)
) togetherWith
fadeOut(animationSpec = tween(durationMillis = 140))
},
predictivePopTransitionSpec = { _ ->
fadeIn(
animationSpec = tween(
durationMillis = 140
)
) togetherWith
fadeOut(animationSpec = tween(durationMillis = 140))
},
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = appEntryProvider
)
}
}
} else {
LoginScreen()

View File

@@ -1,5 +1,8 @@
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.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
@@ -8,3 +11,11 @@ val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }
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
import androidx.compose.runtime.CompositionLocalProvider
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import hu.bbara.purefin.ui.screen.library.LibraryScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.AppScreen
@@ -11,10 +13,18 @@ import hu.bbara.purefin.ui.screen.series.SeriesScreen
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Home> {
AppScreen()
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
AppScreen()
}
}
entry<Route.HomeSearchRoute> {
HomeSearchFullScreen()
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
HomeSearchFullScreen()
}
}
entry<Route.MovieRoute> {
MovieScreen(movie = it.item)

View File

@@ -1,5 +1,9 @@
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.size
import androidx.compose.foundation.shape.CircleShape
@@ -21,7 +25,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
fun HomeTopBar(
onSearchClick: () -> Unit,
@@ -30,11 +38,34 @@ fun HomeTopBar(
onLogoutClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
var isProfileMenuExpanded by remember { mutableStateOf(false) }
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(
onClick = onSearchClick,
colors = IconButtonColors(
@@ -43,7 +74,7 @@ fun HomeTopBar(
disabledContainerColor = scheme.surface,
disabledContentColor = scheme.onSurface
),
modifier = Modifier.size(50.dp),
modifier = searchButtonModifier,
) {
Icon(
imageVector = Icons.Outlined.Search,
@@ -97,4 +128,4 @@ fun HomeTopBar(
)
}
}
}
}

View File

@@ -1,5 +1,9 @@
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.background
import androidx.compose.foundation.layout.Arrangement
@@ -51,17 +55,41 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
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
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun HomeSearchFullScreen(
modifier: Modifier = Modifier
) {
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(
modifier = modifier
.then(sharedBoundsModifier)
.fillMaxSize()
.background(scheme.background)
.statusBarsPadding()