diff --git a/banner/src/main/kotlin/com/imashnake/animite/banner/BannerLayout.kt b/banner/src/main/kotlin/com/imashnake/animite/banner/BannerLayout.kt index 5cdfb273..da50a8ef 100644 --- a/banner/src/main/kotlin/com/imashnake/animite/banner/BannerLayout.kt +++ b/banner/src/main/kotlin/com/imashnake/animite/banner/BannerLayout.kt @@ -1,5 +1,6 @@ package com.imashnake.animite.banner +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -30,6 +32,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.util.fastCoerceIn import com.imashnake.animite.core.ui.LocalPaddings +import com.imashnake.animite.core.ui.ext.thenIf /** * Most screens and pages follow a banner-style layout in Animite. @@ -74,6 +77,7 @@ fun NestedScrollBannerLayout( banner: @Composable BoxScope.(Float, Modifier) -> Unit, bannerElevatedContent: @Composable BoxScope.(Float) -> Unit, content: @Composable ColumnScope.() -> Unit, + isExpanded: Boolean, modifier: Modifier = Modifier, maxBannerHeight: Dp = dimensionResource(R.dimen.banner_height), contentPadding: PaddingValues = PaddingValues(), @@ -86,6 +90,16 @@ fun NestedScrollBannerLayout( var bannerHeightPx by remember { mutableFloatStateOf(maxBannerHeightPx) } var ratio by remember { mutableFloatStateOf(1f) } + LaunchedEffect(isExpanded) { + if (isExpanded) { + ratio = 1f + bannerHeightPx = maxBannerHeightPx + } else { + ratio = 0f + bannerHeightPx = minBannerHeightPx + } + } + // Copied from TopAppBar.ExitUntilCollapsedScrollBehavior val nestedScrollConnection = remember { object : NestedScrollConnection { @@ -133,17 +147,18 @@ fun NestedScrollBannerLayout( } } - Box(modifier.nestedScroll(nestedScrollConnection)) { + Box(modifier.thenIf(condition = isExpanded) { nestedScroll(nestedScrollConnection) }) { + val bannerHeight by animateFloatAsState(bannerHeightPx) banner( ratio, Modifier - .height(with(density) { bannerHeightPx.toDp() }) + .height(with(density) { bannerHeight.toDp() }) .fillMaxWidth() ) Column( modifier = Modifier .fillMaxSize() - .padding(top = with(density) { bannerHeightPx.toDp() }) + .padding(top = with(density) { bannerHeight.toDp() }) .background(contentBackgroundColor) .padding(contentPadding), verticalArrangement = verticalArrangement diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt index 1a6c462a..bb24c68e 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition @@ -66,9 +67,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -182,18 +185,22 @@ fun ProfileScreen( var isRefreshing by remember { mutableStateOf(false) } val pullToRefreshState = rememberPullToRefreshState() + + var isBannerExpanded by remember { mutableStateOf(true) } PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = { viewModel.refresh { isRefreshing = it } }, state = pullToRefreshState, ) { NestedScrollBannerLayout( + isExpanded = isBannerExpanded, banner = { ratio, modifier -> + val animRatio by animateFloatAsState(ratio) Box(Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { AsyncImage( model = crossfadeModel(banner), contentDescription = null, - alpha = 1.2f * ratio - 0.2f, + alpha = 1.2f * animRatio - 0.2f, modifier = modifier, contentScale = ContentScale.Crop ) @@ -212,12 +219,15 @@ fun ProfileScreen( topEnd = LocalPaddings.current.small, ) ) - .graphicsLayer { alpha = 1.5f * ratio - 0.5f }, + .graphicsLayer { alpha = 1.5f * animRatio - 0.5f }, ) } }, bannerElevatedContent = { ratio -> + val animRatio by animateFloatAsState(ratio) SettingsAndMore( + isBannerExpanded = isBannerExpanded, + onBannerExpanded = { isBannerExpanded = !isBannerExpanded }, onNavigateToSettings = onNavigateToSettings, logOut = { isLogOutDialogShown = true }, expanded = isDropdownExpanded, @@ -230,7 +240,7 @@ fun ProfileScreen( bottom = LocalPaddings.current.large, end = LocalPaddings.current.large ) - .padding(top = LocalPaddings.current.large * ratio) + .padding(top = LocalPaddings.current.large * animRatio) ) }, content = { @@ -341,6 +351,8 @@ fun ProfileScreen( @Composable private fun SettingsAndMore( + isBannerExpanded: Boolean, + onBannerExpanded: () -> Unit, onNavigateToSettings: (SettingsPage) -> Unit, expanded: Boolean, setExpanded: (Boolean) -> Unit, @@ -358,6 +370,8 @@ private fun SettingsAndMore( verticalAlignment = Alignment.CenterVertically, modifier = modifier ) { + BannerHeightToggle(isBannerExpanded, onBannerExpanded) + SettingsIcon(onNavigateToSettings = onNavigateToSettings) Box { @@ -447,6 +461,35 @@ fun SettingsIcon( } } +@Composable +fun BannerHeightToggle( + isExpanded: Boolean, + onToggleBannerHeight: () -> Unit, + modifier: Modifier = Modifier +) { + val haptic = LocalHapticFeedback.current + val angle by animateFloatAsState(if (isExpanded) 0f else 180f) + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = CircleShape, + modifier = modifier, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.banner_toggle), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onToggleBannerHeight() + } + .padding(LocalPaddings.current.small) + .size(16.dp) + .graphicsLayer { rotationZ = angle } + ) + } +} + @Composable fun LogOutDialog( logOut: () -> Unit, diff --git a/profile/src/main/res/drawable/banner_toggle.xml b/profile/src/main/res/drawable/banner_toggle.xml new file mode 100644 index 00000000..c57f89ef --- /dev/null +++ b/profile/src/main/res/drawable/banner_toggle.xml @@ -0,0 +1,24 @@ + + + +