Reduce Compose overhead on lower-end device.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Alex Hart
2026-05-05 16:12:01 -03:00
committed by Greyson Parrelli
parent 370fca3c89
commit 4dd5a4ee53
3 changed files with 139 additions and 10 deletions
@@ -21,6 +21,8 @@ import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import kotlin.reflect.typeOf
private val callLinkRoomIdType = typeOf<CallLinkRoomId>()
fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) {
composable<MainNavigationDetailLocation.Empty> {
EmptyDetailScreen()
@@ -28,7 +30,7 @@ fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) {
composable<MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
informNavigatorWeAreReady()
@@ -40,7 +42,7 @@ fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) {
composable<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
callLinkRoomIdType to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
informNavigatorWeAreReady()
@@ -62,6 +62,10 @@ import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import kotlin.reflect.typeOf
import kotlin.time.Duration.Companion.milliseconds
private val conversationArgsType = typeOf<ConversationArgs>()
private val recipientIdType = typeOf<RecipientId>()
private val messageIdType = typeOf<MessageId>()
fun NavGraphBuilder.chatNavGraphBuilder(
chatNavGraphState: ChatNavGraphState
) {
@@ -71,7 +75,7 @@ fun NavGraphBuilder.chatNavGraphBuilder(
composable<MainNavigationDetailLocation.Chats.Conversation>(
typeMap = mapOf(
typeOf<ConversationArgs>() to JsonSerializableNavType(ConversationArgs.serializer())
conversationArgsType to JsonSerializableNavType(ConversationArgs.serializer())
)
) { navBackStackEntry ->
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.Conversation>()
@@ -147,8 +151,8 @@ fun NavGraphBuilder.chatNavGraphBuilder(
composable<MainNavigationDetailLocation.Chats.MessageDetails>(
typeMap = mapOf(
typeOf<RecipientId>() to JsonSerializableNavType(RecipientId.serializer()),
typeOf<MessageId>() to MessageId.NavType()
recipientIdType to JsonSerializableNavType(RecipientId.serializer()),
messageIdType to MessageId.NavType()
)
) { navBackStackEntry ->
val context = LocalContext.current
@@ -173,7 +177,7 @@ fun NavGraphBuilder.chatNavGraphBuilder(
composable<MainNavigationDetailLocation.Chats.ConversationSettings>(
typeMap = mapOf(
typeOf<RecipientId>() to JsonSerializableNavType(RecipientId.serializer())
recipientIdType to JsonSerializableNavType(RecipientId.serializer())
)
) { navBackStackEntry ->
@@ -5,8 +5,16 @@
package org.thoughtcrime.securesms.window
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.snap
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@@ -24,6 +32,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionState
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
@@ -31,6 +40,7 @@ import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
@@ -41,13 +51,14 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.isWidthExpanded
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback
@@ -87,6 +98,11 @@ enum class NavigationType {
* A top-level scaffold that automatically adapts its layout based on the device's window size class. It is a generic container designed to handle the
* arrangement of navigation rails, top/bottom bars, and list-detail pane management for both compact and large screens.
*
* On phone-class layouts (single horizontal partition) running on devices that predate predictive back (API < 33),
* this dispatches to [SinglePaneAppScaffold], which skips [NavigableListDetailPaneScaffold] / [ThreePaneScaffold] and
* its lookahead measurement pass. The scaffold's seek-driven predictive back animation never fires on those devices,
* so we pay no UX cost for the simpler implementation.
*
* @param topBarContent An optional top bar that spans across all panes.
*
* @param primaryContent The main content, which is typically the detail view in a split-pane layout.
@@ -95,10 +111,10 @@ enum class NavigationType {
* @param navRailContent The side navigation rail, shown on medium and larger screen sizes.
* @param bottomNavContent The bottom navigation bar, shown on compact screen sizes.
*
* @param paneExpansionState Manages the position and expansion of the panes in a list-detail layout.
* @param paneExpansionDragHandle An optional drag handle used to resize panes in the list-detail layout.
* @param paneExpansionState Manages the position and expansion of the panes in a list-detail layout. Ignored by [SinglePaneAppScaffold].
* @param paneExpansionDragHandle An optional drag handle used to resize panes in the list-detail layout. Ignored by [SinglePaneAppScaffold].
*
* @param animatorFactory Provides animations to control how panes enter and exit the screen during navigation.
* @param animatorFactory Provides animations to control how panes enter and exit the screen during navigation. Ignored by [SinglePaneAppScaffold].
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
@@ -115,6 +131,52 @@ fun AppScaffold(
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default
) {
val useSimpleScaffold = navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
if (useSimpleScaffold) {
SinglePaneAppScaffold(
navigator = navigator,
modifier = modifier,
topBarContent = topBarContent,
primaryContent = primaryContent,
secondaryContent = secondaryContent,
bottomNavContent = bottomNavContent,
snackbarHost = snackbarHost,
contentWindowInsets = contentWindowInsets
)
} else {
AdaptiveAppScaffold(
navigator = navigator,
modifier = modifier,
topBarContent = topBarContent,
primaryContent = primaryContent,
secondaryContent = secondaryContent,
navRailContent = navRailContent,
bottomNavContent = bottomNavContent,
paneExpansionState = paneExpansionState,
paneExpansionDragHandle = paneExpansionDragHandle,
snackbarHost = snackbarHost,
contentWindowInsets = contentWindowInsets,
animatorFactory = animatorFactory
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun AdaptiveAppScaffold(
navigator: AppScaffoldNavigator<Any>,
modifier: Modifier = Modifier,
topBarContent: @Composable () -> Unit = {},
primaryContent: @Composable () -> Unit = {},
secondaryContent: @Composable () -> Unit,
navRailContent: @Composable () -> Unit = {},
bottomNavContent: @Composable () -> Unit = {},
paneExpansionState: PaneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null,
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default
) {
val minPaneWidth = navigator.scaffoldDirective.defaultPanePreferredWidth
val navigationState = navigator.state
@@ -229,6 +291,67 @@ fun AppScaffold(
}
}
/**
* Phone-only scaffold that swaps content between [secondaryContent] and [primaryContent] without using
* [NavigableListDetailPaneScaffold]. Avoids the lookahead measurement pass and deep adaptive layout tree
* that drives ANR on low-end devices.
*
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun SinglePaneAppScaffold(
navigator: AppScaffoldNavigator<Any>,
modifier: Modifier = Modifier,
topBarContent: @Composable () -> Unit = {},
primaryContent: @Composable () -> Unit = {},
secondaryContent: @Composable () -> Unit,
bottomNavContent: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars
) {
val showDetail = navigator.scaffoldValue.primary == PaneAdaptedValue.Expanded
val coroutineScope = rememberCoroutineScope()
BackHandler(enabled = navigator.canNavigateBack()) {
coroutineScope.launch { navigator.navigateBack() }
}
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets,
topBar = topBarContent,
snackbarHost = snackbarHost,
modifier = modifier
) { paddingValues ->
AnimatedContent(
targetState = showDetail,
transitionSpec = {
val transform = if (targetState) {
slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth } togetherWith
slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth }
} else {
slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth } togetherWith
slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth }
}
transform using SizeTransform(clip = false) { _, _ -> snap() }
},
modifier = Modifier.padding(paddingValues),
label = "SimpleAppScaffold"
) { isDetail ->
if (isDetail) {
primaryContent()
} else {
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f)) {
secondaryContent()
}
bottomNavContent()
}
}
}
}
}
@Composable
private fun ListAndNavigation(
topBarContent: @Composable () -> Unit,