From c5e795b176e9e37ef7598ca52767f10fbbe5abac Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 17 Apr 2025 13:14:50 -0300 Subject: [PATCH] Wire up nav rail fabs and fix animation playing on leaving a tab. --- .../thoughtcrime/securesms/MainActivity.kt | 1 + .../securesms/main/MainBottomChrome.kt | 13 +- .../main/MainFloatingActionButtons.kt | 182 ++++++++++++++---- .../securesms/main/MainNavigation.kt | 51 +---- .../securesms/window/AppScaffold.kt | 13 +- 5 files changed, 170 insertions(+), 90 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index e1a4c5d94e..2449cb674b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -268,6 +268,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner if (isNavigationVisible) { MainNavigationRail( state = mainNavigationState, + mainFloatingActionButtonsCallback = mainBottomChromeCallback, onDestinationSelected = mainNavigationCallback ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt index 55058da70b..6228cfffaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -43,10 +43,7 @@ data class SnackbarState( ) } -interface MainBottomChromeCallback { - fun onNewChatClick() - fun onNewCallClick() - fun onCameraClick(destination: MainNavigationListLocation) +interface MainBottomChromeCallback : MainFloatingActionButtonsCallback { fun onMegaphoneVisible(megaphone: Megaphone) fun onSnackbarDismissed() @@ -79,21 +76,21 @@ fun MainBottomChrome( megaphoneActionController: MegaphoneActionController, modifier: Modifier = Modifier ) { + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + Column( modifier = modifier .fillMaxWidth() .animateContentSize() ) { - if (state.mainToolbarMode == MainToolbarMode.FULL) { + if (state.mainToolbarMode == MainToolbarMode.FULL && windowSizeClass.isCompact()) { Box( contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxWidth() ) { MainFloatingActionButtons( destination = state.destination, - onCameraClick = callback::onCameraClick, - onNewCallClick = callback::onNewCallClick, - onNewChatClick = callback::onNewChatClick + callback = callback ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt index 5acb6c5f4e..b207d5b217 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt @@ -12,6 +12,7 @@ import androidx.compose.animation.core.animateDp import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -20,6 +21,7 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -38,69 +40,129 @@ import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.window.Navigation -import org.thoughtcrime.securesms.window.WindowSizeClass import kotlin.math.roundToInt private val ACTION_BUTTON_SIZE = 56.dp private val ACTION_BUTTON_SPACING = 16.dp +interface MainFloatingActionButtonsCallback { + fun onNewChatClick() + fun onNewCallClick() + fun onCameraClick(destination: MainNavigationListLocation) + + object Empty : MainFloatingActionButtonsCallback { + override fun onNewChatClick() = Unit + override fun onNewCallClick() = Unit + override fun onCameraClick(destination: MainNavigationListLocation) = Unit + } +} + @Composable fun MainFloatingActionButtons( destination: MainNavigationListLocation, - onNewChatClick: () -> Unit = {}, - onCameraClick: (MainNavigationListLocation) -> Unit = {}, - onNewCallClick: () -> Unit = {} + callback: MainFloatingActionButtonsCallback, + modifier: Modifier = Modifier, + navigation: Navigation = Navigation.rememberNavigation() ) { - val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() - if (windowSizeClass.navigation == Navigation.RAIL) { - return - } - val boxHeightDp = (ACTION_BUTTON_SIZE * 2 + ACTION_BUTTON_SPACING) val boxHeightPx = with(LocalDensity.current) { boxHeightDp.toPx().roundToInt() } + val primaryButtonAlignment = remember(navigation) { + when (navigation) { + Navigation.RAIL -> Alignment.TopCenter + Navigation.BAR -> Alignment.BottomCenter + } + } + + val shadowElevation: Dp = remember(navigation) { + when (navigation) { + Navigation.RAIL -> 0.dp + Navigation.BAR -> 4.dp + } + } + Box( - modifier = Modifier + modifier = modifier .padding(ACTION_BUTTON_SPACING) .height(boxHeightDp) ) { - AnimatedVisibility( - visible = destination == MainNavigationListLocation.CHATS, - modifier = Modifier.align(Alignment.TopCenter), - enter = slideInVertically(initialOffsetY = { boxHeightPx - it }), - exit = slideOutVertically(targetOffsetY = { boxHeightPx - it }) - ) { - val elevation by transition.animateDp(targetValueByState = { if (it == EnterExitState.Visible) 4.dp else 0.dp }) - - CameraButton( - colors = IconButtonDefaults.filledTonalIconButtonColors().copy( - containerColor = SignalTheme.colors.colorSurface1 - ), - onClick = { - onCameraClick(MainNavigationListLocation.CHATS) - }, - shadowElevation = elevation - ) - } + SecondaryActionButton( + destination = destination, + boxHeightPx = boxHeightPx, + onCameraClick = callback::onCameraClick, + elevation = shadowElevation + ) Box( - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier.align(primaryButtonAlignment) ) { PrimaryActionButton( destination = destination, - onNewChatClick = onNewChatClick, - onCameraClick = onCameraClick, - onNewCallClick = onNewCallClick + onNewChatClick = callback::onNewChatClick, + onCameraClick = callback::onCameraClick, + onNewCallClick = callback::onNewCallClick, + elevation = shadowElevation ) } } } +@Composable +private fun BoxScope.SecondaryActionButton( + destination: MainNavigationListLocation, + boxHeightPx: Int, + elevation: Dp, + onCameraClick: (MainNavigationListLocation) -> Unit +) { + val navigation = Navigation.rememberNavigation() + val secondaryButtonAlignment = remember(navigation) { + when (navigation) { + Navigation.RAIL -> Alignment.BottomCenter + Navigation.BAR -> Alignment.TopCenter + } + } + + val offsetYProvider: (Int) -> Int = remember(navigation) { + when (navigation) { + Navigation.RAIL -> { + { it - boxHeightPx } + } + Navigation.BAR -> { + { boxHeightPx - it } + } + } + } + + AnimatedVisibility( + visible = destination == MainNavigationListLocation.CHATS, + modifier = Modifier.align(secondaryButtonAlignment), + enter = slideInVertically(initialOffsetY = offsetYProvider), + exit = slideOutVertically(targetOffsetY = offsetYProvider) + ) { + val animatedElevation by transition.animateDp(targetValueByState = { if (it == EnterExitState.Visible) elevation else 0.dp }) + + CameraButton( + colors = IconButtonDefaults.filledTonalIconButtonColors().copy( + containerColor = when (navigation) { + Navigation.RAIL -> MaterialTheme.colorScheme.surface + Navigation.BAR -> SignalTheme.colors.colorSurface2 + }, + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = { + onCameraClick(MainNavigationListLocation.CHATS) + }, + shadowElevation = animatedElevation + ) + } +} + @Composable private fun PrimaryActionButton( destination: MainNavigationListLocation, + elevation: Dp, onNewChatClick: () -> Unit = {}, onCameraClick: (MainNavigationListLocation) -> Unit = {}, onNewCallClick: () -> Unit = {} @@ -117,6 +179,7 @@ private fun PrimaryActionButton( MainFloatingActionButton( onClick = onClick, + shadowElevation = elevation, icon = { AnimatedContent(destination) { targetState -> val icon = when (targetState) { @@ -178,15 +241,58 @@ private fun MainFloatingActionButton( @SignalPreview @Composable -private fun MainFloatingActionButtonsPreview() { - var destination by remember { mutableStateOf(MainNavigationListLocation.CHATS) } +private fun MainFloatingActionButtonsNavigationRailPreview() { + var currentDestination by remember { mutableStateOf(MainNavigationListLocation.CHATS) } + val callback = remember { + object : MainFloatingActionButtonsCallback { + override fun onCameraClick(destination: MainNavigationListLocation) { + currentDestination = MainNavigationListLocation.CALLS + } + + override fun onNewChatClick() { + currentDestination = MainNavigationListLocation.STORIES + } + + override fun onNewCallClick() { + currentDestination = MainNavigationListLocation.CHATS + } + } + } Previews.Preview { MainFloatingActionButtons( - destination = destination, - onCameraClick = { destination = MainNavigationListLocation.CALLS }, - onNewChatClick = { destination = MainNavigationListLocation.STORIES }, - onNewCallClick = { destination = MainNavigationListLocation.CHATS } + destination = currentDestination, + callback = callback, + navigation = Navigation.RAIL + ) + } +} + +@SignalPreview +@Composable +private fun MainFloatingActionButtonsNavigationBarPreview() { + var currentDestination by remember { mutableStateOf(MainNavigationListLocation.CHATS) } + val callback = remember { + object : MainFloatingActionButtonsCallback { + override fun onCameraClick(destination: MainNavigationListLocation) { + currentDestination = MainNavigationListLocation.CALLS + } + + override fun onNewChatClick() { + currentDestination = MainNavigationListLocation.STORIES + } + + override fun onNewCallClick() { + currentDestination = MainNavigationListLocation.CHATS + } + } + } + + Previews.Preview { + MainFloatingActionButtons( + destination = currentDestination, + callback = callback, + navigation = Navigation.BAR ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt index 83d1e4207c..d8c4b789f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt @@ -19,9 +19,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -40,12 +37,10 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.IntSize @@ -214,48 +209,17 @@ private fun Modifier.drawNavigationBarBadge(count: Int, compact: Boolean): Modif @Composable fun MainNavigationRail( state: MainNavigationState, + mainFloatingActionButtonsCallback: MainFloatingActionButtonsCallback, onDestinationSelected: (MainNavigationListLocation) -> Unit ) { NavigationRail( containerColor = SignalTheme.colors.colorSurface1, header = { - FilledTonalIconButton( - onClick = { }, - shape = RoundedCornerShape(18.dp), - modifier = Modifier - .padding(top = 56.dp, bottom = 16.dp) - .size(56.dp), - enabled = true, - colors = IconButtonDefaults.filledTonalIconButtonColors() - .copy( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onBackground - ) - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.symbol_edit_24), - contentDescription = null - ) - } - - FilledTonalIconButton( - onClick = { }, - shape = RoundedCornerShape(18.dp), - modifier = Modifier - .padding(bottom = 80.dp) - .size(56.dp), - enabled = true, - colors = IconButtonDefaults.filledTonalIconButtonColors() - .copy( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.symbol_camera_24), - contentDescription = null - ) - } + MainFloatingActionButtons( + destination = state.selectedDestination, + callback = mainFloatingActionButtonsCallback, + modifier = Modifier.padding(vertical = 40.dp) + ) } ) { val entries = remember(state.isStoriesFeatureEnabled) { @@ -351,7 +315,7 @@ private fun NavigationDestinationIcon( LottieAnimation( composition = composition, - progress = { progress }, + progress = { if (selected) progress else 0f }, dynamicProperties = dynamicProperties, modifier = Modifier.size(LOTTIE_SIZE) ) @@ -383,6 +347,7 @@ private fun MainNavigationRailPreview() { storiesCount = 5, selectedDestination = selected ), + mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty, onDestinationSelected = { selected = it } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt index 8d7ebffe9e..55f6a60bbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -38,6 +38,7 @@ import androidx.window.core.layout.WindowHeightSizeClass import androidx.window.core.layout.WindowWidthSizeClass import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback import org.thoughtcrime.securesms.main.MainNavigationBar import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationState @@ -45,7 +46,16 @@ import org.thoughtcrime.securesms.util.RemoteConfig enum class Navigation { RAIL, - BAR + BAR; + + companion object { + @Composable + fun rememberNavigation(): Navigation { + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + + return remember(windowSizeClass) { windowSizeClass.navigation } + } + } } /** @@ -275,6 +285,7 @@ private fun AppScaffoldPreview() { navRailContent = { MainNavigationRail( state = MainNavigationState(), + mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty, onDestinationSelected = {} ) },