Dynamic split pane support via internal setting.

This commit is contained in:
Alex Hart
2025-04-17 17:39:10 -03:00
committed by Cody Henthorne
parent 2cfe321274
commit 893725e304
10 changed files with 132 additions and 79 deletions

View File

@@ -29,11 +29,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -93,6 +94,7 @@ import org.thoughtcrime.securesms.main.MainToolbar
import org.thoughtcrime.securesms.main.MainToolbarCallback
import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
import org.thoughtcrime.securesms.main.SnackbarState
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
@@ -236,9 +238,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
MainContainer {
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator<Any>(
scaffoldDirective = calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(
scaffoldDirective = calculatePaneScaffoldDirective(
currentWindowAdaptiveInfo()
).copy(
maxHorizontalPartitions = if (windowSizeClass.isSplitPane()) 2 else 1,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
@@ -258,10 +261,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
navigator = scaffoldNavigator,
bottomNavContent = {
if (isNavigationVisible) {
MainNavigationBar(
state = mainNavigationState,
onDestinationSelected = mainNavigationCallback
)
Column(
modifier = Modifier
.clip(contentLayoutData.navigationBarShape)
.background(color = SignalTheme.colors.colorSurface2)
) {
MainNavigationBar(
state = mainNavigationState,
onDestinationSelected = mainNavigationCallback
)
if (!windowSizeClass.isSplitPane()) {
NavigationBarSpacerCompat()
}
}
}
},
navRailContent = {
@@ -360,11 +373,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
@Composable
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val modifier = if (windowSizeClass.isLandscape()) {
Modifier.displayCutoutPadding()
} else {
Modifier
}
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
val backgroundColor = if (windowSizeClass.isCompact()) {
@@ -373,6 +381,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
SignalTheme.colors.colorSurface1
}
val modifier = if (windowSizeClass.isSplitPane()) {
Modifier.systemBarsPadding().displayCutoutPadding()
} else {
Modifier
}
BoxWithConstraints(
modifier = Modifier
.background(color = backgroundColor)

View File

@@ -174,6 +174,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
switchPref(
isEnabled = state.largeScreenUi,
title = DSLSettingsText.from("Force split pane UI on landscape phones."),
summary = DSLSettingsText.from("This setting requires split pane UI to be enabled."),
isChecked = state.forceSplitPaneOnCompactLandscape,
onClick = {
viewModel.setForceSplitPaneOnCompactLandscape(!state.forceSplitPaneOnCompactLandscape)
}
)
sectionHeaderPref(DSLSettingsText.from("Playgrounds"))
clickPref(

View File

@@ -26,5 +26,6 @@ data class InternalSettingsState(
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val newCallingUi: Boolean,
val largeScreenUi: Boolean
val largeScreenUi: Boolean,
val forceSplitPaneOnCompactLandscape: Boolean
)

View File

@@ -167,7 +167,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
newCallingUi = SignalStore.internal.newCallingUi,
largeScreenUi = SignalStore.internal.largeScreenUi
largeScreenUi = SignalStore.internal.largeScreenUi,
forceSplitPaneOnCompactLandscape = SignalStore.internal.forceSplitPaneOnCompactLandscape
)
fun onClearOnboardingState() {
@@ -188,6 +189,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setForceSplitPaneOnCompactLandscape(forceSplitPaneOnCompactLandscape: Boolean) {
SignalStore.internal.forceSplitPaneOnCompactLandscape = forceSplitPaneOnCompactLandscape
refresh()
}
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))

View File

@@ -590,7 +590,7 @@ class ConversationFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setUseWindowTypes(resources.getWindowSizeClass().isCompact())
binding.root.setUseWindowTypes(!resources.getWindowSizeClass().isSplitPane())
disposables.bindTo(viewLifecycleOwner)
@@ -1370,9 +1370,7 @@ class ConversationFragment :
}
private fun presentNavigationIconForNormal() {
val windowSizeClass = resources.getWindowSizeClass()
if (windowSizeClass.isCompact()) {
if (!resources.getWindowSizeClass().isSplitPane()) {
binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24)
binding.toolbar.setNavigationContentDescription(R.string.ConversationFragment__content_description_back_button)
binding.toolbar.setNavigationOnClickListener {

View File

@@ -29,6 +29,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val ENCODE_HEVC: String = "internal.hevc_encoding"
const val NEW_CALL_UI: String = "internal.new.call.ui"
const val LARGE_SCREEN_UI: String = "internal.large.screen.ui"
const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -40,6 +41,11 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
*/
var largeScreenUi by booleanValue(LARGE_SCREEN_UI, false).defaultForExternalUsers()
/**
* Force split-pane mode on compact landscape
*/
var forceSplitPaneOnCompactLandscape by booleanValue(FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE, false).defaultForExternalUsers()
/**
* Members will not be added directly to a GV2 even if they could be.
*/

View File

@@ -15,10 +15,14 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.thoughtcrime.securesms.window.WindowSizeClass
private val MEDIUM_CONTENT_CORNERS = 18.dp
private val EXTENDED_CONTENT_CORNERS = 14.dp
/**
* Describes metrics for the content layout (list and detail) of the main screen.
*
* @param shape The shape of each of the list and detail fragments
* @param shape The clipping shape of each of the list and detail fragments
* @param navigationBarShape The clipping shape applied to the navigation bar, if present.
* @param partitionWidth The width of the divider between list and detail
* @param listPaddingStart The padding between the list pane and the navigation rail
* @param detailPaddingEnd The padding at the end of the detail pane
@@ -26,6 +30,7 @@ import org.thoughtcrime.securesms.window.WindowSizeClass
@Immutable
data class MainContentLayoutData(
val shape: Shape,
val navigationBarShape: Shape,
val partitionWidth: Dp,
val listPaddingStart: Dp,
val detailPaddingEnd: Dp
@@ -49,9 +54,9 @@ data class MainContentLayoutData(
return remember(maxWidth, windowSizeClass) {
when {
windowSizeClass.isCompact() -> maxWidth
windowSizeClass.isMedium() -> (maxWidth - extraPadding) / 2f
else -> 416.dp
!windowSizeClass.isSplitPane() -> maxWidth
windowSizeClass.isExtended() -> 416.dp
else -> (maxWidth - extraPadding) / 2f
}
}
}
@@ -67,24 +72,29 @@ data class MainContentLayoutData(
return remember(windowSizeClass) {
MainContentLayoutData(
shape = when {
windowSizeClass.isCompact() -> RectangleShape
windowSizeClass.isMedium() -> RoundedCornerShape(18.dp)
else -> RoundedCornerShape(14.dp)
!windowSizeClass.isSplitPane() -> RectangleShape
windowSizeClass.isExtended() -> RoundedCornerShape(EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(MEDIUM_CONTENT_CORNERS)
},
navigationBarShape = when {
!windowSizeClass.isSplitPane() -> RectangleShape
windowSizeClass.isExtended() -> 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 {
windowSizeClass.isCompact() -> 0.dp
windowSizeClass.isMedium() -> 13.dp
else -> 16.dp
!windowSizeClass.isSplitPane() -> 0.dp
windowSizeClass.isExtended() -> 16.dp
else -> 13.dp
},
listPaddingStart = when {
windowSizeClass.isCompact() -> 0.dp
windowSizeClass.isMedium() -> 12.dp
else -> 16.dp
!windowSizeClass.isSplitPane() -> 0.dp
windowSizeClass.isExtended() -> 16.dp
else -> 12.dp
},
detailPaddingEnd = when {
windowSizeClass.isCompact() -> 0.dp
windowSizeClass.isMedium() -> 12.dp
else -> 24.dp
!windowSizeClass.isSplitPane() -> 0.dp
windowSizeClass.isExtended() -> 24.dp
else -> 12.dp
}
)
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
@@ -97,50 +96,46 @@ fun MainNavigationBar(
state: MainNavigationState,
onDestinationSelected: (MainNavigationListLocation) -> Unit
) {
Column(modifier = Modifier.background(color = SignalTheme.colors.colorSurface2)) {
NavigationBar(
containerColor = SignalTheme.colors.colorSurface2,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.height(if (state.compact) 48.dp else 80.dp),
windowInsets = WindowInsets(0, 0, 0, 0)
) {
val entries = remember(state.isStoriesFeatureEnabled) {
if (state.isStoriesFeatureEnabled) {
MainNavigationListLocation.entries
} else {
MainNavigationListLocation.entries.filterNot { it == MainNavigationListLocation.STORIES }
}
}
entries.forEach { destination ->
val badgeCount = when (destination) {
MainNavigationListLocation.CHATS -> state.chatsCount
MainNavigationListLocation.CALLS -> state.callsCount
MainNavigationListLocation.STORIES -> state.storiesCount
}
val selected = state.selectedDestination == destination
NavigationBarItem(
selected = selected,
icon = {
NavigationDestinationIcon(
destination = destination,
selected = selected
)
},
label = if (state.compact) null else {
{ NavigationDestinationLabel(destination) }
},
onClick = {
onDestinationSelected(destination)
},
modifier = Modifier.drawNavigationBarBadge(count = badgeCount, compact = state.compact)
)
NavigationBar(
containerColor = SignalTheme.colors.colorSurface2,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.height(if (state.compact) 48.dp else 80.dp),
windowInsets = WindowInsets(0, 0, 0, 0)
) {
val entries = remember(state.isStoriesFeatureEnabled) {
if (state.isStoriesFeatureEnabled) {
MainNavigationListLocation.entries
} else {
MainNavigationListLocation.entries.filterNot { it == MainNavigationListLocation.STORIES }
}
}
NavigationBarSpacerCompat()
entries.forEach { destination ->
val badgeCount = when (destination) {
MainNavigationListLocation.CHATS -> state.chatsCount
MainNavigationListLocation.CALLS -> state.callsCount
MainNavigationListLocation.STORIES -> state.storiesCount
}
val selected = state.selectedDestination == destination
NavigationBarItem(
selected = selected,
icon = {
NavigationDestinationIcon(
destination = destination,
selected = selected
)
},
label = if (state.compact) null else {
{ NavigationDestinationLabel(destination) }
},
onClick = {
onDestinationSelected(destination)
},
modifier = Modifier.drawNavigationBarBadge(count = badgeCount, compact = state.compact)
)
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
@@ -18,7 +19,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.PaneExpansionState
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
@@ -83,6 +84,14 @@ enum class WindowSizeClass(
fun isLandscape(): Boolean = this == COMPACT_LANDSCAPE || this == MEDIUM_LANDSCAPE || this == EXTENDED_LANDSCAPE
fun isSplitPane(): Boolean {
return if (SignalStore.internal.largeScreenUi && SignalStore.internal.forceSplitPaneOnCompactLandscape) {
this != COMPACT_PORTRAIT
} else {
this.navigation != Navigation.BAR
}
}
companion object {
@OptIn(ExperimentalWindowCoreApi::class)
fun Resources.getWindowSizeClass(): WindowSizeClass {
@@ -221,7 +230,11 @@ private fun ListAndNavigation(
bottomNavContent: @Composable () -> Unit,
windowSizeClass: WindowSizeClass
) {
Row {
Row(
modifier = if (windowSizeClass.isLandscape()) {
Modifier.displayCutoutPadding()
} else Modifier
) {
if (windowSizeClass.navigation == Navigation.RAIL) {
navRailContent()
}
@@ -250,7 +263,7 @@ private fun AppScaffoldPreview() {
Previews.Preview {
AppScaffold(
navigator = rememberListDetailPaneScaffoldNavigator<Any>(
scaffoldDirective = calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(
scaffoldDirective = calculatePaneScaffoldDirective(
currentWindowAdaptiveInfo()
).copy(
horizontalPartitionSpacerSize = 10.dp