Add breakpoint helper and expand device previews.

Co-authored-by: jeffrey-signal <jeffrey@signal.org>
This commit is contained in:
Alex Hart
2026-04-06 13:18:35 -03:00
committed by Greyson Parrelli
parent a7bb2831f8
commit 264447a6d9
4 changed files with 173 additions and 24 deletions
@@ -12,9 +12,12 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.isSplitPane
private val MEDIUM_CONTENT_CORNERS = 18.dp
@@ -70,25 +73,27 @@ data class MainContentLayoutData(
@Composable
fun rememberContentLayoutData(mode: MainToolbarMode): MainContentLayoutData {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val resources = LocalResources.current
val breakpoint = resources.getWindowBreakpoint()
return remember(windowSizeClass, mode) {
return remember(windowSizeClass, mode, breakpoint) {
val isSplitPane = windowSizeClass.isSplitPane()
val isWidthExpanded = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)
val isLargeWindowSize = breakpoint == WindowBreakpoint.LARGE
MainContentLayoutData(
shape = when {
!isSplitPane -> RectangleShape
isWidthExpanded -> RoundedCornerShape(EXTENDED_CONTENT_CORNERS)
isLargeWindowSize -> RoundedCornerShape(EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(MEDIUM_CONTENT_CORNERS)
},
navigationBarShape = when {
!isSplitPane -> RectangleShape
isWidthExpanded -> RoundedCornerShape(0.dp, 0.dp, EXTENDED_CONTENT_CORNERS, EXTENDED_CONTENT_CORNERS)
isLargeWindowSize -> RoundedCornerShape(0.dp, 0.dp, EXTENDED_CONTENT_CORNERS, EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(0.dp, 0.dp, MEDIUM_CONTENT_CORNERS, MEDIUM_CONTENT_CORNERS)
},
partitionWidth = when {
!isSplitPane -> 0.dp
isWidthExpanded -> 24.dp
isLargeWindowSize -> 24.dp
else -> 13.dp
},
listPaddingStart = when {
@@ -102,7 +107,7 @@ data class MainContentLayoutData(
},
detailPaddingEnd = when {
!isSplitPane -> 0.dp
isWidthExpanded -> 24.dp
isLargeWindowSize -> 24.dp
else -> 12.dp
}
)
@@ -38,12 +38,17 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
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.unit.dp
import androidx.compose.ui.zIndex
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.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationRail
@@ -57,14 +62,21 @@ enum class NavigationType {
companion object {
@Composable
fun rememberNavigationType(): NavigationType {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val resources = LocalResources.current
val config = LocalConfiguration.current
val windowBreakpoint = remember(config) { resources.getWindowBreakpoint() }
return remember(windowSizeClass) {
if (windowSizeClass.isSplitPane()) {
RAIL
} else {
BAR
return when (windowBreakpoint) {
WindowBreakpoint.SMALL -> BAR
WindowBreakpoint.MEDIUM -> {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (windowSizeClass.isWidthExpanded) {
RAIL
} else {
BAR
}
}
WindowBreakpoint.LARGE -> RAIL
}
}
}
@@ -265,7 +277,7 @@ private fun AppScaffoldPreview() {
AppScaffold(
navigator = rememberAppScaffoldNavigator(
isSplitPane = windowSizeClass.isSplitPane(),
isSplitPane = windowSizeClass.isSplitPane(false),
defaultPanePreferredWidth = 416.dp,
horizontalPartitionSpacerSize = 16.dp
),
@@ -21,6 +21,9 @@ val WindowSizeClass.isWidthCompact
val WindowSizeClass.isHeightCompact
get() = !isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND)
val WindowSizeClass.isWidthExpanded
get() = isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)
fun Resources.getWindowSizeClass(): WindowSizeClass {
return WindowSizeClass.BREAKPOINTS_V1.computeWindowSizeClass(
widthDp = displayMetrics.widthPixels / displayMetrics.density,
@@ -28,6 +31,53 @@ fun Resources.getWindowSizeClass(): WindowSizeClass {
)
}
/**
* Determines the device's form factor (PHONE, FOLDABLE, or TABLET) based on the current
* [Resources] and window size class.
*
* This function uses several heuristics:
* - Returns [WindowBreakpoint.SMALL] if the width or height is compact.
* - Returns [WindowBreakpoint.LARGE] if the height is at least the expanded lower bound.
* - Returns [WindowBreakpoint.MEDIUM] if the width is at least the medium lower bound.
* - Otherwise, falls back to aspect ratio heuristics: wider (≥ 1.6) is [WindowBreakpoint.LARGE], else [WindowBreakpoint.MEDIUM].
*
* @return the inferred [WindowBreakpoint] for the current device.
*/
fun Resources.getWindowBreakpoint(): WindowBreakpoint {
val windowSizeClass = getWindowSizeClass()
if (windowSizeClass.isWidthCompact || windowSizeClass.isHeightCompact) {
return WindowBreakpoint.SMALL
}
if (windowSizeClass.isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_EXPANDED_LOWER_BOUND)) {
return WindowBreakpoint.LARGE
}
val numerator = maxOf(displayMetrics.widthPixels, displayMetrics.heightPixels)
val denominator = minOf(displayMetrics.widthPixels, displayMetrics.heightPixels)
val aspectRatio = numerator.toFloat() / denominator
return if (aspectRatio >= 1.6f) {
WindowBreakpoint.LARGE
} else {
WindowBreakpoint.MEDIUM
}
}
/**
* Indicates the general form factor of the device for responsive UI purposes.
*
* - [SMALL]: A window similar to a phone-sized device, typically with a compact width or height.
* - [MEDIUM]: A window similar to a foldable or medium-size device, or a device which doesn't obviously fit into phone or tablet by heuristics.
* - [LARGE]: A window similar to a large-screen tablet device, typically with an expanded height or wide aspect ratio.
*/
enum class WindowBreakpoint {
SMALL,
MEDIUM,
LARGE
}
/**
* Determines whether the UI should display in split-pane mode based on available screen space.
*/
@@ -40,7 +90,7 @@ fun WindowSizeClass.isSplitPane(
}
return isAtLeastBreakpoint(
widthDpBreakpoint = WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND,
widthDpBreakpoint = WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND,
heightDpBreakpoint = WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND
)
}
@@ -19,27 +19,109 @@ annotation class NightPreview()
annotation class DayNightPreviews
@Preview(name = "phone portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=360dp,height=640dp,orientation=portrait")
@Preview(name = "phone portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=360dp,height=640dp,orientation=portrait")
annotation class PhonePortraitDayPreview
@Preview(name = "phone landscape (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=640dp,height=360dp,orientation=landscape")
annotation class PhoneLandscapeDayPreview
@PhonePortraitDayPreview
@PhoneLandscapeDayPreview
annotation class PhoneDayPreviews
@Preview(name = "phone portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=360dp,height=640dp,orientation=portrait")
annotation class PhonePortraitNightPreview
@Preview(name = "phone landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=640dp,height=360dp,orientation=landscape")
annotation class PhoneLandscapeNightPreview
@PhonePortraitNightPreview
@PhoneLandscapeNightPreview
annotation class PhoneNightPreviews
@PhoneDayPreviews
@PhoneLandscapeNightPreview
annotation class PhonePreviews
@Preview(name = "foldable portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=600dp,height=1024dp,orientation=portrait")
@Preview(name = "foldable landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1024dp,height=600dp,orientation=landscape")
@Preview(name = "small foldable portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=620dp,height=720dp,orientation=portrait")
annotation class SmallFoldablePortraitDayPreview
@Preview(name = "small foldable landscape (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=720dp,height=620dp,orientation=landscape")
annotation class SmallFoldableLandscapeDayPreview
@SmallFoldablePortraitDayPreview
@SmallFoldableLandscapeDayPreview
annotation class SmallFoldableDayPreviews
@Preview(name = "small foldable portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=620dp,height=720dp,orientation=portrait")
annotation class SmallFoldablePortraitNightPreview
@Preview(name = "small foldable landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=720dp,height=620dp,orientation=landscape")
annotation class SmallFoldableLandscapeNightPreview
@SmallFoldablePortraitNightPreview
@SmallFoldableLandscapeNightPreview
annotation class SmallFoldableNightPreviews
@SmallFoldableDayPreviews
@SmallFoldableLandscapeNightPreview
annotation class SmallFoldablePreviews
@Preview(name = "foldable portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=850dp,height=881dp,orientation=portrait")
annotation class FoldablePortraitDayPreview
@Preview(name = "foldable landscape (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=881dp,height=850dp,orientation=landscape")
annotation class FoldableLandscapeDayPreview
@FoldablePortraitDayPreview
@FoldableLandscapeDayPreview
annotation class FoldableDayPreviews
@Preview(name = "foldable portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=850dp,height=881dp,orientation=portrait")
annotation class FoldablePortraitNightPreview
@Preview(name = "foldable landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=881dp,height=850dp,orientation=landscape")
annotation class FoldableLandscapeNightPreview
@FoldablePortraitNightPreview
@FoldableLandscapeNightPreview
annotation class FoldableNightPreviews
@FoldableDayPreviews
@FoldableLandscapeNightPreview
annotation class FoldablePreviews
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=portrait")
@Preview(name = "tablet portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=840dp,height=1280dp,orientation=portrait")
annotation class TabletPortraitDayPreview
@Preview(name = "tablet landscape (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=1280dp,height=840dp,orientation=landscape")
annotation class TabletLandscapeDayPreview
@TabletPortraitDayPreview
@TabletLandscapeDayPreview
annotation class TabletDayPreviews
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1280dp,height=840dp,orientation=portrait")
annotation class TabletPortraitNightPreview
@Preview(name = "tablet landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=landscape")
annotation class TabletLandscapeNightPreview
@TabletPortraitNightPreview
@TabletLandscapeNightPreview
annotation class TabletNightPreviews
@TabletDayPreviews
@TabletLandscapeNightPreview
annotation class TabletPreviews
@Preview(name = "phone portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=360dp,height=640dp,orientation=portrait")
@Preview(name = "phone landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=640dp,height=360dp,orientation=landscape")
@Preview(name = "foldable portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=600dp,height=1024dp,orientation=portrait")
@Preview(name = "foldable landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1024dp,height=600dp,orientation=landscape")
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=portrait")
@Preview(name = "tablet landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1280dp,height=840dp,orientation=landscape")
@PhoneNightPreviews
@SmallFoldableNightPreviews
@FoldableNightPreviews
@TabletNightPreviews
annotation class AllNightPreviews
@PhonePreviews
@SmallFoldablePreviews
@FoldablePreviews
@TabletPreviews
annotation class AllDevicePreviews