Create foldable/large screen helper module (#70)

* Create window-info module

* Update build.gradle settings

* Add helper classes

* Update ComposeGallery to use module

* Update to wm beta04 and twopanelayout alpha10

* Update Compose Gallery tests to follow new app structure

* Rename module and classes

* Update NavigationRail sample to use module

* Add window mode (~device posture) to window state

* Update companion pane to use module

* Fix module manifest formatting

* Update dual view to use module

* Update list detail to use module

* Update two page to use module

* Refactor WindowState properties

* Expose additional fold info in WindowState

fold state, isSeparating, and occlusion type

* Rename module to window state

* Rename package to windowstate

* Update large screen logic to only include expanded size class

* Add tests for window state module

* Remove FoldingFeature dependency in WindowState

* Remove redundant JWM dependencies

* Update ComposeGallery tests to use new fold state enum

* Remove unnecessary JWM imports from ComposeGallery tests
This commit is contained in:
Kristen Halper 2021-12-10 15:11:37 -05:00 коммит произвёл GitHub
Родитель c36a3bcb4c
Коммит d4cb092e21
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
40 изменённых файлов: 652 добавлений и 413 удалений

Просмотреть файл

@ -35,7 +35,6 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime
@ -44,6 +43,7 @@ dependencies {
implementation composeDependencies.activityCompose
implementation microsoftDependencies.twoPaneLayout
implementation project(':WindowState')
implementation googleDependencies.material
}

Просмотреть файл

@ -5,7 +5,6 @@
package com.microsoft.device.display.samples.companionpane
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@ -26,7 +25,6 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -34,14 +32,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoRepository
import com.microsoft.device.display.samples.companionpane.uicomponent.BrightnessPanel
import com.microsoft.device.display.samples.companionpane.uicomponent.DefinitionPanel
import com.microsoft.device.display.samples.companionpane.uicomponent.EffectPanel
@ -51,56 +46,20 @@ import com.microsoft.device.display.samples.companionpane.uicomponent.MagicWandP
import com.microsoft.device.display.samples.companionpane.uicomponent.ShortFilterControl
import com.microsoft.device.display.samples.companionpane.uicomponent.VignettePanel
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneLayout
import kotlinx.coroutines.flow.collect
import com.microsoft.device.dualscreen.windowstate.WindowMode
import com.microsoft.device.dualscreen.windowstate.WindowState
private val shortSlideWidth = 200.dp
private val longSlideWidth = 350.dp
const val SMALLEST_TABLET_SCREEN_WIDTH_DP = 585
enum class ScreenState {
SinglePortrait,
SingleLandscape,
DualPortrait,
DualLandscape
}
@Composable
fun SetupUI(windowInfoRep: WindowInfoRepository) {
var screenState by remember { mutableStateOf(ScreenState.SinglePortrait) }
var isAppSpanned by remember { mutableStateOf(false) }
var isHingeHorizontal by remember { mutableStateOf(false) }
LaunchedEffect(windowInfoRep) {
windowInfoRep.windowLayoutInfo
.collect { newLayoutInfo ->
val displayFeatures = newLayoutInfo.displayFeatures
isAppSpanned = displayFeatures.isNotEmpty()
if (isAppSpanned) {
val foldingFeature = displayFeatures.first() as? FoldingFeature
foldingFeature?.let {
isHingeHorizontal = it.orientation == FoldingFeature.Orientation.HORIZONTAL
}
}
}
}
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp
val isTablet = smallestScreenWidthDp > SMALLEST_TABLET_SCREEN_WIDTH_DP
val isDualScreen = (isAppSpanned || isTablet)
screenState =
if (isDualScreen) {
val showDualLandscape = if (isAppSpanned) isHingeHorizontal else isPortrait
if (showDualLandscape) ScreenState.DualLandscape else ScreenState.DualPortrait
} else {
// NOTE: the LocalConfiguration orientation info should only be used in single screen mode
if (isPortrait) ScreenState.SinglePortrait else ScreenState.SingleLandscape
}
fun CompanionPaneApp(windowState: WindowState) {
var windowMode by remember { mutableStateOf(WindowMode.SINGLE_PORTRAIT) }
windowMode = windowState.windowMode
TwoPaneLayout(
pane1 = { Pane1(screenState) },
pane2 = { Pane2(screenState) },
pane1 = { Pane1(windowMode) },
pane2 = { Pane2(windowMode) },
)
}
@ -127,14 +86,14 @@ fun ShowWithTopBar(content: @Composable () -> Unit, title: String? = null) {
}
@Composable
fun Pane1(screenState: ScreenState) {
fun Pane1(windowMode: WindowMode) {
ShowWithTopBar(
content = {
when (screenState) {
ScreenState.SinglePortrait -> PortraitLayout()
ScreenState.SingleLandscape -> LandscapeLayout()
ScreenState.DualPortrait -> DualPortraitPane1()
ScreenState.DualLandscape -> DualLandscapePane1()
when (windowMode) {
WindowMode.SINGLE_PORTRAIT -> PortraitLayout()
WindowMode.SINGLE_LANDSCAPE -> LandscapeLayout()
WindowMode.DUAL_PORTRAIT -> DualPortraitPane1()
WindowMode.DUAL_LANDSCAPE -> DualLandscapePane1()
}
},
title = stringResource(R.string.app_name)
@ -142,14 +101,14 @@ fun Pane1(screenState: ScreenState) {
}
@Composable
fun Pane2(screenState: ScreenState) {
when (screenState) {
ScreenState.DualPortrait -> {
fun Pane2(windowMode: WindowMode) {
when (windowMode) {
WindowMode.DUAL_PORTRAIT -> {
ShowWithTopBar(
content = { DualPortraitPane2() }
)
}
ScreenState.DualLandscape -> DualLandscapePane2()
WindowMode.DUAL_LANDSCAPE -> DualLandscapePane2()
else -> {}
}
}

Просмотреть файл

@ -8,21 +8,21 @@ package com.microsoft.device.display.samples.companionpane
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.window.layout.WindowInfoRepository
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import com.microsoft.device.display.samples.companionpane.ui.CompanionPaneAppsTheme
import com.microsoft.device.dualscreen.windowstate.WindowState
import com.microsoft.device.dualscreen.windowstate.rememberWindowState
class MainActivity : AppCompatActivity() {
private lateinit var windowInfoRep: WindowInfoRepository
private lateinit var windowState: WindowState
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowInfoRep = windowInfoRepository()
setContent {
windowState = rememberWindowState()
CompanionPaneAppsTheme {
SetupUI(windowInfoRep)
CompanionPaneApp(windowState)
}
}
}

Просмотреть файл

@ -40,7 +40,6 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime
@ -49,6 +48,7 @@ dependencies {
implementation composeDependencies.activityCompose
implementation microsoftDependencies.twoPaneLayout
implementation project(':WindowState')
implementation googleDependencies.material

Просмотреть файл

@ -5,6 +5,7 @@
package com.microsoft.device.display.samples.composegallery
import android.graphics.Rect
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
@ -26,6 +27,9 @@ import com.microsoft.device.display.samples.composegallery.ui.view.ComposeGaller
import com.microsoft.device.dualscreen.testutils.getString
import com.microsoft.device.dualscreen.testutils.simulateHorizontalFold
import com.microsoft.device.dualscreen.testutils.simulateVerticalFold
import com.microsoft.device.dualscreen.windowstate.FoldState
import com.microsoft.device.dualscreen.windowstate.WindowSizeClass
import com.microsoft.device.dualscreen.windowstate.WindowState
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
@ -52,8 +56,16 @@ class PaneSynchronizationTest {
composeTestRule.setContent {
ComposeGalleryTheme {
ComposeGalleryApp(
foldableState = FoldableState(hasFold = true, isFoldHorizontal = false),
widthSizeClass = WindowSizeClass.Medium
WindowState(
hasFold = true,
isFoldHorizontal = false,
foldBounds = Rect(),
foldState = FoldState.HALF_OPENED,
foldSeparates = true,
foldOccludes = true,
widthSizeClass = WindowSizeClass.MEDIUM,
heightSizeClass = WindowSizeClass.MEDIUM
)
)
}
}
@ -93,8 +105,16 @@ class PaneSynchronizationTest {
composeTestRule.setContent {
ComposeGalleryTheme {
ComposeGalleryApp(
foldableState = FoldableState(hasFold = false, isFoldHorizontal = false),
widthSizeClass = WindowSizeClass.Compact
WindowState(
hasFold = false,
isFoldHorizontal = false,
foldBounds = Rect(),
foldState = FoldState.HALF_OPENED,
foldSeparates = true,
foldOccludes = true,
widthSizeClass = WindowSizeClass.COMPACT,
heightSizeClass = WindowSizeClass.MEDIUM
)
)
}
}
@ -126,8 +146,16 @@ class PaneSynchronizationTest {
composeTestRule.setContent {
ComposeGalleryTheme {
ComposeGalleryApp(
foldableState = FoldableState(hasFold = true, isFoldHorizontal = true),
widthSizeClass = WindowSizeClass.Compact
WindowState(
hasFold = true,
isFoldHorizontal = true,
foldBounds = Rect(),
foldState = FoldState.HALF_OPENED,
foldSeparates = true,
foldOccludes = true,
widthSizeClass = WindowSizeClass.COMPACT,
heightSizeClass = WindowSizeClass.MEDIUM
)
)
}
}

Просмотреть файл

@ -5,6 +5,7 @@
package com.microsoft.device.display.samples.composegallery
import android.graphics.Rect
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
@ -21,6 +22,9 @@ import com.microsoft.device.display.samples.composegallery.ui.view.ComposeGaller
import com.microsoft.device.display.samples.composegallery.ui.view.DetailPane
import com.microsoft.device.display.samples.composegallery.ui.view.ListPane
import com.microsoft.device.dualscreen.testutils.getString
import com.microsoft.device.dualscreen.windowstate.FoldState
import com.microsoft.device.dualscreen.windowstate.WindowSizeClass
import com.microsoft.device.dualscreen.windowstate.WindowState
import org.junit.Rule
import org.junit.Test
@ -178,8 +182,16 @@ class TopAppBarTest {
composeTestRule.setContent {
ComposeGalleryTheme {
ComposeGalleryApp(
foldableState = FoldableState(hasFold = false, isFoldHorizontal = false),
widthSizeClass = WindowSizeClass.Compact
WindowState(
hasFold = false,
isFoldHorizontal = false,
foldBounds = Rect(),
foldState = FoldState.HALF_OPENED,
foldSeparates = true,
foldOccludes = true,
widthSizeClass = WindowSizeClass.COMPACT,
heightSizeClass = WindowSizeClass.MEDIUM
)
)
}
}

Просмотреть файл

@ -5,102 +5,26 @@
package com.microsoft.device.display.samples.composegallery
import android.app.Activity
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import com.microsoft.device.display.samples.composegallery.ui.ComposeGalleryTheme
import com.microsoft.device.display.samples.composegallery.ui.view.ComposeGalleryApp
import kotlinx.coroutines.flow.collect
enum class WindowSizeClass { Compact, Medium, Expanded }
data class FoldableState(val hasFold: Boolean, val isFoldHorizontal: Boolean)
import com.microsoft.device.dualscreen.windowstate.WindowState
import com.microsoft.device.dualscreen.windowstate.rememberWindowState
class MainActivity : AppCompatActivity() {
private lateinit var windowState: WindowState
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val widthSizeClass = rememberWidthSizeClass()
val foldableState = rememberFoldableState()
windowState = rememberWindowState()
ComposeGalleryTheme {
ComposeGalleryApp(foldableState, widthSizeClass)
ComposeGalleryApp(windowState)
}
}
}
}
@Composable
fun Activity.rememberFoldableState(): FoldableState {
val windowInfoRepo = windowInfoRepository()
var hasFold by remember { mutableStateOf(false) }
var isFoldHorizontal by remember { mutableStateOf(false) }
LaunchedEffect(windowInfoRepo) {
windowInfoRepo.windowLayoutInfo.collect { newLayoutInfo ->
hasFold = newLayoutInfo.displayFeatures.isNotEmpty()
if (hasFold) {
val fold = newLayoutInfo.displayFeatures.firstOrNull() as? FoldingFeature
fold?.let {
isFoldHorizontal = it.orientation == FoldingFeature.Orientation.HORIZONTAL
}
}
}
}
return FoldableState(hasFold, isFoldHorizontal)
}
/**
* Implementation taken from JetNews sample
* https://github.com/android/compose-samples/blob/main/JetNews/app/src/main/java/com/example/jetnews/utils/WindowSize.kt
*
* Remembers the [WindowSizeClass] class for the window corresponding to the current window metrics.
*/
@Composable
fun rememberWidthSizeClass(): WindowSizeClass {
// Get the size (in pixels) of the window
val windowSize = rememberWindowSize()
// Calculate the width window size class
return getWindowSizeClass(windowSize)
}
/**
* Remembers the [Size] in pixels of the window corresponding to the current window metrics.
*/
@Composable
private fun rememberWindowSize(): Dp {
val configuration = LocalConfiguration.current
val windowMetrics = remember(configuration) {
configuration.smallestScreenWidthDp.dp
}
return windowMetrics
}
/**
* Partitions a [Dp] into a enumerated [WindowSizeClass] class.
*/
@VisibleForTesting
fun getWindowSizeClass(windowDpWidth: Dp): WindowSizeClass = when {
windowDpWidth < 0.dp -> throw IllegalArgumentException("Dp value cannot be negative")
windowDpWidth < 600.dp -> WindowSizeClass.Compact
windowDpWidth < 840.dp -> WindowSizeClass.Medium
else -> WindowSizeClass.Expanded
}

Просмотреть файл

@ -5,7 +5,6 @@
package com.microsoft.device.display.samples.composegallery.ui.view
import android.content.res.Configuration
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@ -16,27 +15,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.microsoft.device.display.samples.composegallery.FoldableState
import com.microsoft.device.display.samples.composegallery.R
import com.microsoft.device.display.samples.composegallery.WindowSizeClass
import com.microsoft.device.display.samples.composegallery.models.DataProvider
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneLayout
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneMode
import com.microsoft.device.dualscreen.windowstate.WindowState
@Composable
fun ComposeGalleryApp(foldableState: FoldableState, widthSizeClass: WindowSizeClass) {
fun ComposeGalleryApp(windowState: WindowState) {
// Check if app should be in dual mode
val isDualPortraitFoldable = foldableState.hasFold && !foldableState.isFoldHorizontal
val isLargeScreen = !foldableState.hasFold && widthSizeClass != WindowSizeClass.Compact
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val isDualMode = isDualPortraitFoldable || (isLargeScreen && isLandscape)
val isDualMode = windowState.isDualPortrait()
// Get relevant image data for the panes
val models = DataProvider.imageModels

Просмотреть файл

@ -35,7 +35,6 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime
@ -46,4 +45,5 @@ dependencies {
implementation googleDependencies.material
implementation microsoftDependencies.twoPaneLayout
implementation project(':WindowState')
}

Просмотреть файл

@ -9,25 +9,26 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.window.layout.WindowInfoRepository
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import com.microsoft.device.display.samples.dualview.models.AppStateViewModel
import com.microsoft.device.display.samples.dualview.ui.home.SetupUI
import com.microsoft.device.display.samples.dualview.ui.home.DualViewApp
import com.microsoft.device.display.samples.dualview.ui.theme.DualViewComposeSampleTheme
import com.microsoft.device.dualscreen.windowstate.WindowState
import com.microsoft.device.dualscreen.windowstate.rememberWindowState
class MainActivity : AppCompatActivity() {
private lateinit var windowInfoRep: WindowInfoRepository
private lateinit var windowState: WindowState
private lateinit var appStateViewModel: AppStateViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowInfoRep = windowInfoRepository()
appStateViewModel = ViewModelProvider(this).get(AppStateViewModel::class.java)
setContent {
windowState = rememberWindowState()
DualViewComposeSampleTheme {
SetupUI(appStateViewModel, windowInfoRep)
DualViewApp(appStateViewModel, windowState)
}
}
}

Просмотреть файл

@ -7,58 +7,18 @@ package com.microsoft.device.display.samples.dualview.ui.home
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoRepository
import com.microsoft.device.display.samples.dualview.models.AppStateViewModel
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneLayout
import kotlinx.coroutines.flow.collect
private lateinit var appStateViewModel: AppStateViewModel
const val SMALLEST_TABLET_SCREEN_WIDTH_DP = 585
import com.microsoft.device.dualscreen.windowstate.WindowState
@Composable
fun SetupUI(viewModel: AppStateViewModel, windowInfoRep: WindowInfoRepository) {
appStateViewModel = viewModel
var isAppSpanned by remember { mutableStateOf(false) }
LaunchedEffect(windowInfoRep) {
windowInfoRep.windowLayoutInfo
.collect { newLayoutInfo ->
val displayFeatures = newLayoutInfo.displayFeatures
isAppSpanned = displayFeatures.isNotEmpty()
var viewWidth = 0
if (isAppSpanned) {
val foldingFeature = displayFeatures.first() as FoldingFeature
viewWidth = if (foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL) {
foldingFeature.bounds.left
} else {
foldingFeature.bounds.top
}
}
appStateViewModel.viewWidth = viewWidth
}
}
val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp
val isTablet = smallestScreenWidthDp > SMALLEST_TABLET_SCREEN_WIDTH_DP
val isDualScreen = isAppSpanned || isTablet
DualScreenUI(isDualScreen)
}
@Composable
fun DualScreenUI(isDualScreen: Boolean) {
fun DualViewApp(viewModel: AppStateViewModel, windowState: WindowState) {
TwoPaneLayout(
pane1 = { RestaurantViewWithTopBar(isDualScreen = isDualScreen, appStateViewModel = appStateViewModel) },
pane2 = { MapViewWithTopBar(isDualScreen = isDualScreen, appStateViewModel = appStateViewModel) }
pane1 = { RestaurantViewWithTopBar(windowState.isDualScreen(), viewModel) },
pane2 = { MapViewWithTopBar(windowState.isDualScreen(), viewModel) }
)
}

Просмотреть файл

@ -35,7 +35,6 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime

Просмотреть файл

@ -35,7 +35,6 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime
@ -46,4 +45,5 @@ dependencies {
implementation googleDependencies.material
implementation microsoftDependencies.twoPaneLayout
implementation project(':WindowState')
}

Просмотреть файл

@ -9,25 +9,26 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.window.layout.WindowInfoRepository
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import com.microsoft.device.display.samples.listdetail.models.AppStateViewModel
import com.microsoft.device.display.samples.listdetail.ui.theme.ListDetailComposeSampleTheme
import com.microsoft.device.display.samples.listdetail.ui.view.SetupUI
import com.microsoft.device.display.samples.listdetail.ui.view.ListDetailApp
import com.microsoft.device.dualscreen.windowstate.WindowState
import com.microsoft.device.dualscreen.windowstate.rememberWindowState
class MainActivity : AppCompatActivity() {
private lateinit var windowInfoRep: WindowInfoRepository
private lateinit var windowState: WindowState
private lateinit var appStateViewModel: AppStateViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowInfoRep = windowInfoRepository()
appStateViewModel = ViewModelProvider(this).get(AppStateViewModel::class.java)
setContent {
windowState = rememberWindowState()
ListDetailComposeSampleTheme {
SetupUI(appStateViewModel, windowInfoRep)
ListDetailApp(appStateViewModel, windowState)
}
}
}

Просмотреть файл

@ -44,13 +44,13 @@ private val verticalPadding = 35.dp
private val horizontalPadding = 20.dp
@Composable
fun DetailViewWithTopBar(isAppSpanned: Boolean, appStateViewModel: AppStateViewModel) {
fun DetailViewWithTopBar(isDualScreen: Boolean, appStateViewModel: AppStateViewModel) {
Scaffold(
topBar = {
TopAppBar(
title = { },
navigationIcon = {
if (!isAppSpanned) {
if (!isDualScreen) {
DetailViewTopBar()
}
}

Просмотреть файл

@ -5,53 +5,21 @@
package com.microsoft.device.display.samples.listdetail.ui.view
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.window.layout.WindowInfoRepository
import com.microsoft.device.display.samples.listdetail.models.AppStateViewModel
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneLayout
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneMode
import kotlinx.coroutines.flow.collect
private lateinit var appStateViewModel: AppStateViewModel
const val SMALLEST_TABLET_SCREEN_WIDTH_DP = 585
import com.microsoft.device.dualscreen.windowstate.WindowState
@Composable
fun SetupUI(viewModel: AppStateViewModel, windowInfoRep: WindowInfoRepository) {
var isAppSpanned by remember { mutableStateOf(false) }
LaunchedEffect(windowInfoRep) {
windowInfoRep.windowLayoutInfo
.collect { newLayoutInfo ->
val displayFeatures = newLayoutInfo.displayFeatures
isAppSpanned = displayFeatures.isNotEmpty()
}
}
appStateViewModel = viewModel
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp
val isTablet = smallestScreenWidthDp > SMALLEST_TABLET_SCREEN_WIDTH_DP
val isDualScreen = (isAppSpanned || isTablet) && !isPortrait
DualScreenUI(isDualScreen)
}
@Composable
fun DualScreenUI(isDualScreen: Boolean) {
fun ListDetailApp(viewModel: AppStateViewModel, windowState: WindowState) {
TwoPaneLayout(
paneMode = TwoPaneMode.HorizontalSingle,
pane1 = { ListViewWithTopBar(appStateViewModel = appStateViewModel) },
pane2 = { DetailViewWithTopBar(isAppSpanned = isDualScreen, appStateViewModel = appStateViewModel) }
pane1 = { ListViewWithTopBar(viewModel) },
pane2 = { DetailViewWithTopBar(windowState.isDualPortrait(), viewModel) }
)
}

Просмотреть файл

@ -41,9 +41,9 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation microsoftDependencies.twoPaneLayout
implementation project(':WindowState')
implementation composeDependencies.composeMaterialForNavRail
implementation composeDependencies.composeRuntime

Просмотреть файл

@ -12,27 +12,26 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.window.layout.WindowInfoRepository
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import com.microsoft.device.display.samples.navigationrail.ui.theme.ComposeSamplesTheme
import com.microsoft.device.display.samples.navigationrail.ui.view.SetupUI
import com.microsoft.device.display.samples.navigationrail.ui.view.NavigationRailApp
import com.microsoft.device.dualscreen.windowstate.WindowState
import com.microsoft.device.dualscreen.windowstate.rememberWindowState
@ExperimentalAnimationApi
@ExperimentalMaterialApi
@ExperimentalFoundationApi
@ExperimentalUnitApi
class MainActivity : AppCompatActivity() {
private lateinit var windowInfoRep: WindowInfoRepository
private lateinit var windowState: WindowState
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowInfoRep = windowInfoRepository()
setContent {
windowState = rememberWindowState()
ComposeSamplesTheme {
// Set up app UI
SetupUI(windowInfoRep)
NavigationRailApp(windowState)
}
}
}

Просмотреть файл

@ -50,7 +50,7 @@ private enum class DrawerState { Collapsed, Expanded }
* @param collapseHeight: height of the drawer when collpased (in dp)
* @param hingeOccludes: optional param for foldable support, indicates whether there is a hinge
* that occludes content in the current layout
* @param hingeSize: optional param for foldable support, indicates the size of a hinge
* @param foldSize: optional param for foldable support, indicates the size of a fold
* @param hiddenContent: the content that will only be shown when the drawer is expanded
* @param peekContent: the content that will be shown even when the drawer is collapsed
*/
@ -61,7 +61,7 @@ fun ContentDrawer(
expandHeight: Dp,
collapseHeight: Dp,
hingeOccludes: Boolean = false,
hingeSize: Dp = 0.dp,
foldSize: Dp = 0.dp,
hiddenContent: @Composable ColumnScope.() -> Unit,
peekContent: @Composable ColumnScope.() -> Unit,
) {
@ -81,11 +81,11 @@ fun ContentDrawer(
// Check if a spacer needs to be included to render content around an occluding hinge
val spacerHeight = if (hingeOccludes) {
val isExpanding = swipeableState.progress.to == DrawerState.Expanded
val progressHeight = (hingeSize.value * swipeableState.progress.fraction).dp
val progressHeight = (foldSize.value * swipeableState.progress.fraction).dp
if (isExpanding)
progressHeight
else
hingeSize - progressHeight
foldSize - progressHeight
} else {
0.dp
}

Просмотреть файл

@ -5,90 +5,38 @@
package com.microsoft.device.display.samples.navigationrail.ui.view
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.dp
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoRepository
import com.microsoft.device.display.samples.navigationrail.models.DataProvider
import com.microsoft.device.display.samples.navigationrail.ui.components.ItemTopBar
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneLayout
import com.microsoft.device.dualscreen.twopanelayout.TwoPaneMode
import com.microsoft.device.dualscreen.twopanelayout.navigateToPane1
import com.microsoft.device.dualscreen.twopanelayout.navigateToPane2
import kotlinx.coroutines.flow.collect
const val SMALLEST_TABLET_SCREEN_WIDTH_DP = 585
import com.microsoft.device.dualscreen.windowstate.WindowState
@ExperimentalAnimationApi
@ExperimentalUnitApi
@ExperimentalMaterialApi
@ExperimentalFoundationApi
@Composable
fun SetupUI(windowInfoRep: WindowInfoRepository) {
// Create variables to track foldable device layout information
var isAppSpanned by remember { mutableStateOf(false) }
var isHingeVertical by remember { mutableStateOf(false) }
var hingeSize by remember { mutableStateOf(0) }
fun NavigationRailApp(windowState: WindowState) {
// Extract window state information
val isDualScreen = windowState.isDualScreen()
val isDualPortrait = windowState.isDualPortrait()
val isDualLandscape = windowState.isDualLandscape()
val foldSize = windowState.foldSize.dp
LaunchedEffect(windowInfoRep) {
windowInfoRep.windowLayoutInfo
.collect { newLayoutInfo ->
val displayFeatures = newLayoutInfo.displayFeatures
isAppSpanned = displayFeatures.isNotEmpty()
if (isAppSpanned) {
val foldingFeature = displayFeatures.first() as FoldingFeature
isHingeVertical =
foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL
hingeSize = if (isHingeVertical)
foldingFeature.bounds.width()
else
foldingFeature.bounds.height()
}
}
}
val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp
val isTablet = smallestScreenWidthDp > SMALLEST_TABLET_SCREEN_WIDTH_DP
val isDualScreen = (isAppSpanned || isTablet)
val isDualPortrait = when {
isAppSpanned -> isHingeVertical
isTablet -> LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
else -> false
}
val isDualLandscape = when {
isAppSpanned -> !isHingeVertical
isTablet -> LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
else -> false
}
DualScreenUI(isDualScreen, isDualPortrait, isDualLandscape, hingeSize.dp)
}
@ExperimentalAnimationApi
@ExperimentalUnitApi
@ExperimentalFoundationApi
@ExperimentalMaterialApi
@Composable
fun DualScreenUI(
isDualScreen: Boolean,
isDualPortrait: Boolean,
isDualLandscape: Boolean,
hingeSize: Dp,
) {
// Set up starting route for navigation in pane 1
var currentRoute by rememberSaveable { mutableStateOf(navDestinations[0].route) }
val updateRoute: (String) -> Unit = { newRoute -> currentRoute = newRoute }
@ -103,7 +51,7 @@ fun DualScreenUI(
Pane1(isDualScreen, isDualPortrait, imageId, updateImageId, currentRoute, updateRoute)
},
pane2 = {
Pane2(isDualPortrait, isDualLandscape, hingeSize, imageId, updateImageId, currentRoute)
Pane2(isDualPortrait, isDualLandscape, foldSize, imageId, updateImageId, currentRoute)
},
)
@ -137,7 +85,7 @@ fun Pane1(
fun Pane2(
isDualPortrait: Boolean,
isDualLandscape: Boolean,
hingeSize: Dp,
foldSize: Dp,
imageId: Int?,
updateImageId: (Int?) -> Unit,
currentRoute: String,
@ -152,7 +100,7 @@ fun Pane2(
}
BackHandler { if (!isDualPortrait) onBackPressed() }
ItemDetailView(isDualPortrait, isDualLandscape, hingeSize, selectedImage, currentRoute)
ItemDetailView(isDualPortrait, isDualLandscape, foldSize, selectedImage, currentRoute)
// If only one pane is being displayed, show a "back" icon
if (!isDualPortrait) {
ItemTopBar(

Просмотреть файл

@ -26,7 +26,7 @@ import com.microsoft.device.dualscreen.twopanelayout.navigateToPane1
*
* @param isDualPortrait: true if device is in dual portrait mode
* @param isDualLandscape: true if device is in dual landscape mode
* @param hingeSize: size of hinge in dp (0 if no hinge)
* @param foldSize: size of fold in dp (0 if no fold)
* @param selectedImage: currently selected image
* @param currentRoute: current route in gallery NavHost
*/
@ -36,7 +36,7 @@ import com.microsoft.device.dualscreen.twopanelayout.navigateToPane1
fun ItemDetailView(
isDualPortrait: Boolean,
isDualLandscape: Boolean,
hingeSize: Dp,
foldSize: Dp,
selectedImage: Image? = null,
currentRoute: String,
) {
@ -61,7 +61,7 @@ fun ItemDetailView(
modifier = Modifier.align(Alignment.BottomCenter),
image = selectedImage,
isDualLandscape = isDualLandscape,
hingeSize = hingeSize,
foldSize = foldSize,
gallerySection = gallerySection,
)
}

Просмотреть файл

@ -60,7 +60,7 @@ fun BoxWithConstraintsScope.ItemDetailsDrawer(
modifier: Modifier,
image: Image,
isDualLandscape: Boolean,
hingeSize: Dp,
foldSize: Dp,
gallerySection: GallerySections?,
) {
// Set max/min height for drawer based on orientation
@ -84,7 +84,7 @@ fun BoxWithConstraintsScope.ItemDetailsDrawer(
expandHeight = expandedHeight,
collapseHeight = collapsedHeight,
hingeOccludes = isDualLandscape,
hingeSize = hingeSize,
foldSize = foldSize,
hiddenContent = { ItemDetailsLong(image.details) }
) {
DrawerPill()

Просмотреть файл

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!--
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Microsoft Corporation. All rights reserved.
~ Licensed under the MIT License.
-->
<manifest package="com.microsoft.device.dualscreen.testutils"></manifest>
<manifest package="com.microsoft.device.dualscreen.testutils" />

Просмотреть файл

@ -37,13 +37,13 @@ dependencies {
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime
implementation composeDependencies.composeMaterial
implementation composeDependencies.composeUITooling
implementation composeDependencies.activityCompose
implementation project(':WindowState')
implementation googleDependencies.material
}

Просмотреть файл

@ -8,22 +8,22 @@ package com.microsoft.device.display.samples.twopage
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.window.layout.WindowInfoRepository
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import com.microsoft.device.display.samples.twopage.ui.home.SetupUI
import com.microsoft.device.display.samples.twopage.ui.home.TwoPageApp
import com.microsoft.device.display.samples.twopage.ui.theme.TwoPageComposeSamplesTheme
import com.microsoft.device.dualscreen.windowstate.WindowState
import com.microsoft.device.dualscreen.windowstate.rememberWindowState
class MainActivity : AppCompatActivity() {
private lateinit var windowInfoRep: WindowInfoRepository
private lateinit var windowState: WindowState
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowInfoRep = windowInfoRepository()
setContent {
windowState = rememberWindowState()
TwoPageComposeSamplesTheme {
SetupUI(windowInfoRep)
TwoPageApp(windowState)
}
}
}

Просмотреть файл

@ -5,12 +5,10 @@
package com.microsoft.device.display.samples.twopage.ui.home
import android.content.res.Configuration
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -20,64 +18,36 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoRepository
import com.microsoft.device.display.samples.twopage.utils.PagerState
import com.microsoft.device.display.samples.twopage.utils.ViewPager
import kotlinx.coroutines.flow.collect
const val SMALLEST_TABLET_SCREEN_WIDTH_DP = 585
import com.microsoft.device.dualscreen.windowstate.WindowState
@Composable
fun SetupUI(windowInfoRep: WindowInfoRepository) {
fun TwoPageApp(windowState: WindowState) {
val density = LocalDensity.current.density
var isAppSpanned by remember { mutableStateOf(false) }
var viewWidth by remember { mutableStateOf(0) }
var hingeThickness by remember { mutableStateOf(0) }
var isHingeHorizontal by remember { mutableStateOf(false) }
LaunchedEffect(windowInfoRep) {
windowInfoRep.windowLayoutInfo
.collect { newLayoutInfo ->
val displayFeatures = newLayoutInfo.displayFeatures
isAppSpanned = displayFeatures.isNotEmpty()
if (isAppSpanned) {
val foldingFeature = displayFeatures.first() as FoldingFeature
val vWidth: Int
val vWidth = if (windowState.hasFold) {
when (windowState.isFoldHorizontal) {
true -> windowState.foldBounds.right
false -> windowState.foldBounds.left
}
} else 0
viewWidth = if (windowState.isDualPortrait() && !windowState.hasFold)
LocalConfiguration.current.screenWidthDp / 2
else
(vWidth / density).toInt()
if (foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL) {
isHingeHorizontal = false
vWidth = foldingFeature.bounds.left
hingeThickness = foldingFeature.bounds.width()
} else {
isHingeHorizontal = true
vWidth = foldingFeature.bounds.right
hingeThickness = foldingFeature.bounds.height()
}
viewWidth = (vWidth / density).toInt()
}
}
}
val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp
val isTablet = smallestScreenWidthDp > SMALLEST_TABLET_SCREEN_WIDTH_DP
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val isTabletDualMode = isTablet && isLandscape
if (isTabletDualMode) {
viewWidth = LocalConfiguration.current.screenWidthDp / 2
}
val isDualPortraitMode = isAppSpanned && !isHingeHorizontal
val isDualScreen = (isDualPortraitMode) || isTabletDualMode
val isDualScreen = windowState.isDualPortrait()
val pages = setupPages(viewWidth)
PageViews(pages, isDualScreen, hingeThickness / 2)
PageViews(pages, isDualScreen, windowState.foldSize / 2)
}
@Composable
fun PageViews(pages: List<@Composable () -> Unit>, isDualScreen: Boolean, pagePadding: Int) {
val maxPage = (pages.size - 1).coerceAtLeast(0)
val pagerState: PagerState = remember { PagerState(currentPage = 0, minPage = 0, maxPage = maxPage) }
val pagerState: PagerState =
remember { PagerState(currentPage = 0, minPage = 0, maxPage = maxPage) }
pagerState.isDualMode = isDualScreen
ViewPager(
state = pagerState,
@ -94,17 +64,9 @@ fun setupPages(width: Int): List<@Composable () -> Unit> {
.fillMaxHeight()
.clipToBounds() else Modifier.fillMaxSize()
return listOf<@Composable () -> Unit>(
{
FirstPage(modifier = modifier)
},
{
SecondPage(modifier = modifier)
},
{
ThirdPage(modifier = modifier)
},
{
FourthPage(modifier = modifier)
}
{ FirstPage(modifier = modifier) },
{ SecondPage(modifier = modifier) },
{ ThirdPage(modifier = modifier) },
{ FourthPage(modifier = modifier) }
)
}

1
WindowState/.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
/build

57
WindowState/build.gradle Normal file
Просмотреть файл

@ -0,0 +1,57 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
testInstrumentationRunner rootProject.ext.config.testInstrumentationRunner
// REVISIT: can uncomment if we release this
// versionCode 1
// versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion composeVersion
}
}
dependencies {
implementation kotlinDependencies.kotlinStdlib
implementation androidxDependencies.ktxCore
implementation androidxDependencies.appCompat
implementation androidxDependencies.window
implementation composeDependencies.composeUI
implementation composeDependencies.composeRuntime
implementation composeDependencies.composeMaterial
implementation composeDependencies.composeUITooling
implementation composeDependencies.activityCompose
testImplementation testDependencies.androidJunit
testImplementation testDependencies.androidxTestCore
testImplementation testDependencies.mockitoCore
androidTestImplementation testDependencies.androidxTestCore
androidTestImplementation testDependencies.androidxTestRules
androidTestImplementation testDependencies.androidxTestRunner
androidTestImplementation testDependencies.espressoCore
}

Просмотреть файл

@ -0,0 +1,100 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
import android.graphics.Rect
import org.junit.Assert.assertEquals
import org.junit.Test
class WindowStateTest {
private val hasFold = true
private val foldBounds = Rect(20, 20, 60, 100)
private val foldState = FoldState.HALF_OPENED
private val foldSeparates = true
private val foldOccludes = true
private val widthSizeClass = WindowSizeClass.MEDIUM
private val heightSizeClass = WindowSizeClass.EXPANDED
private val horizontalFold = WindowState(
hasFold,
true,
foldBounds,
foldState,
foldSeparates,
foldOccludes,
widthSizeClass,
heightSizeClass
)
private val verticalFold = WindowState(
hasFold,
false,
foldBounds,
foldState,
foldSeparates,
foldOccludes,
widthSizeClass,
heightSizeClass
)
private val noFoldLargeScreen = WindowState(
hasFold = false,
isFoldHorizontal = true,
foldBounds = Rect(),
foldState = FoldState.FLAT,
foldSeparates = false,
foldOccludes = false,
widthSizeClass = WindowSizeClass.EXPANDED,
heightSizeClass = WindowSizeClass.EXPANDED
)
private val noFoldCompact = WindowState(
hasFold = false,
isFoldHorizontal = true,
foldBounds = Rect(),
foldState = FoldState.FLAT,
foldSeparates = false,
foldOccludes = false,
widthSizeClass = WindowSizeClass.COMPACT,
heightSizeClass = WindowSizeClass.MEDIUM
)
@Test
fun returns_correct_fold_size() {
assertEquals(foldBounds.height(), horizontalFold.foldSize)
assertEquals(foldBounds.width(), verticalFold.foldSize)
assertEquals(0, noFoldLargeScreen.foldSize)
}
@Test
fun portrait_large_screen_returns_dual_land() {
assertEquals(WindowMode.DUAL_LANDSCAPE, noFoldLargeScreen.calculateWindowMode(true))
}
@Test
fun landscape_large_screen_returns_dual_port() {
assertEquals(WindowMode.DUAL_PORTRAIT, noFoldLargeScreen.calculateWindowMode(false))
}
@Test
fun portrait_compact_returns_single_port() {
assertEquals(WindowMode.SINGLE_PORTRAIT, noFoldCompact.calculateWindowMode(true))
}
@Test
fun landscape_compact_returns_single_land() {
assertEquals(WindowMode.SINGLE_LANDSCAPE, noFoldCompact.calculateWindowMode(false))
}
@Test
fun vertical_fold_returns_dual_port() {
assertEquals(WindowMode.DUAL_PORTRAIT, verticalFold.calculateWindowMode(true))
assertEquals(WindowMode.DUAL_PORTRAIT, verticalFold.calculateWindowMode(false))
}
@Test
fun horizontal_fold_returns_dual_land() {
assertEquals(WindowMode.DUAL_LANDSCAPE, horizontalFold.calculateWindowMode(true))
assertEquals(WindowMode.DUAL_LANDSCAPE, horizontalFold.calculateWindowMode(false))
}
}

Просмотреть файл

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Microsoft Corporation. All rights reserved.
~ Licensed under the MIT License.
-->
<manifest package="com.microsoft.device.dualscreen.windowstate" />

Просмотреть файл

@ -0,0 +1,8 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
enum class Dimension { WIDTH, HEIGHT }

Просмотреть файл

@ -0,0 +1,8 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
enum class FoldState { FLAT, HALF_OPENED }

Просмотреть файл

@ -0,0 +1,25 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
/**
* Class that represents the different modes in which content can be displayed in a window,
* depending on window size and orientation
*
* Window modes:
* - single portrait
* - single landscape
* - dual portrait
* - dual landscape
*/
enum class WindowMode {
SINGLE_PORTRAIT,
SINGLE_LANDSCAPE,
DUAL_PORTRAIT,
DUAL_LANDSCAPE;
val isDualScreen: Boolean get() = this == DUAL_PORTRAIT || this == DUAL_LANDSCAPE
}

Просмотреть файл

@ -0,0 +1,30 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class WindowSizeClass { COMPACT, MEDIUM, EXPANDED }
/**
* Calculates size class for a given dimension
*
* @param dimenDp: size of dimension in Dp
* @param dimen: which dimension is being measured (width or height)
*/
fun getWindowSizeClass(dimenDp: Dp, dimen: Dimension = Dimension.WIDTH): WindowSizeClass =
when (dimen) {
Dimension.WIDTH -> getSizeClass(dimenDp, 600.dp, 840.dp)
Dimension.HEIGHT -> getSizeClass(dimenDp, 480.dp, 900.dp)
}
private fun getSizeClass(size: Dp, medium: Dp, expanded: Dp): WindowSizeClass = when {
size < 0.dp -> throw IllegalArgumentException("Dp value cannot be negative")
size < medium -> WindowSizeClass.COMPACT
size < expanded -> WindowSizeClass.MEDIUM
else -> WindowSizeClass.EXPANDED
}

Просмотреть файл

@ -0,0 +1,104 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
import android.content.res.Configuration
import android.graphics.Rect
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
/**
* Data class that contains foldable and large screen information extracted from the Jetpack
* Window Manager library
*
* @param hasFold: true if window contains a FoldingFeature,
* @param isFoldHorizontal: true if window contains a FoldingFeature with a horizontal orientation
* @param foldBounds: Rect object that describes the bound of the FoldingFeature
* @param foldState: state of the fold, based on state property of FoldingFeature
* @param foldSeparates: based on isSeparating property of FoldingFeature
* @param foldOccludes: true if FoldingFeature occlusion type is full
* @param widthSizeClass: size class (compact, medium, or expanded) for window width
* @param heightSizeClass: size class (compact, medium, or expanded) for window height
*/
data class WindowState(
val hasFold: Boolean,
val isFoldHorizontal: Boolean,
val foldBounds: Rect,
val foldState: FoldState,
val foldSeparates: Boolean,
val foldOccludes: Boolean,
val widthSizeClass: WindowSizeClass,
val heightSizeClass: WindowSizeClass,
) {
private val foldableFoldSize = when (isFoldHorizontal) {
true -> foldBounds.height()
false -> foldBounds.width()
}
val foldSize = if (hasFold) foldableFoldSize else 0
val windowMode: WindowMode
@Composable get() {
// REVISIT: should width/height ratio be used instead of orientation?
val isPortrait =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
return calculateWindowMode(isPortrait)
}
@VisibleForTesting
fun calculateWindowMode(isPortrait: Boolean): WindowMode {
// REVISIT: should height class also be considered?
// Also, right now we are considering large screens + foldables mutually exclusive
// (which seems necessary for dualscreen apps), but we may want to think about this
// more and change our approach if we think there are cases where we want an app to
// know about both properties
val isLargeScreen = !hasFold && widthSizeClass == WindowSizeClass.EXPANDED
return when {
hasFold -> {
if (isFoldHorizontal)
WindowMode.DUAL_LANDSCAPE
else
WindowMode.DUAL_PORTRAIT
}
isLargeScreen -> {
if (isPortrait)
WindowMode.DUAL_LANDSCAPE
else
WindowMode.DUAL_PORTRAIT
}
isPortrait -> WindowMode.SINGLE_PORTRAIT
else -> WindowMode.SINGLE_LANDSCAPE
}
}
@Composable
fun isDualScreen(): Boolean {
return windowMode.isDualScreen
}
@Composable
fun isDualPortrait(): Boolean {
return windowMode == WindowMode.DUAL_PORTRAIT
}
@Composable
fun isDualLandscape(): Boolean {
return windowMode == WindowMode.DUAL_LANDSCAPE
}
@Composable
fun isSinglePortrait(): Boolean {
return windowMode == WindowMode.SINGLE_PORTRAIT
}
@Composable
fun isSingleLandscape(): Boolean {
return windowMode == WindowMode.SINGLE_LANDSCAPE
}
}

Просмотреть файл

@ -0,0 +1,74 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
import android.app.Activity
import android.graphics.Rect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowMetricsCalculator
import kotlinx.coroutines.flow.collect
@Composable
fun Activity.rememberWindowState(): WindowState {
val activity = this
val windowInfoRepo = WindowInfoTracker.getOrCreate(activity)
var hasFold by remember { mutableStateOf(false) }
var isFoldHorizontal by remember { mutableStateOf(false) }
var foldBounds by remember { mutableStateOf(Rect()) }
var foldState by remember { mutableStateOf(FoldState.FLAT) }
var foldSeparates by remember { mutableStateOf(false) }
var foldOccludes by remember { mutableStateOf(false) }
LaunchedEffect(windowInfoRepo) {
windowInfoRepo.windowLayoutInfo(activity).collect { newLayoutInfo ->
hasFold = newLayoutInfo.displayFeatures.isNotEmpty()
if (hasFold) {
val fold = newLayoutInfo.displayFeatures.firstOrNull() as? FoldingFeature
fold?.let {
isFoldHorizontal = it.orientation == FoldingFeature.Orientation.HORIZONTAL
foldBounds = it.bounds
foldState = when (it.state) {
FoldingFeature.State.HALF_OPENED -> FoldState.HALF_OPENED
else -> FoldState.FLAT
}
foldSeparates = it.isSeparating
foldOccludes = it.occlusionType == FoldingFeature.OcclusionType.FULL
}
}
}
}
val config = LocalConfiguration.current
val windowMetrics = remember(config) {
WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(this).bounds
}
val windowWidth = with(LocalDensity.current) { windowMetrics.width().toDp() }
val windowHeight = with(LocalDensity.current) { windowMetrics.height().toDp() }
val widthSizeClass = getWindowSizeClass(windowWidth)
val heightSizeClass = getWindowSizeClass(windowHeight, Dimension.HEIGHT)
return WindowState(
hasFold,
isFoldHorizontal,
foldBounds,
foldState,
foldSeparates,
foldOccludes,
widthSizeClass,
heightSizeClass
)
}

Просмотреть файл

@ -0,0 +1,23 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
import org.junit.Assert.assertFalse
import org.junit.Test
class WindowModeTest {
@Test
fun single_modes_are_not_dualscreen() {
assertFalse(WindowMode.SINGLE_LANDSCAPE.isDualScreen)
assertFalse(WindowMode.SINGLE_PORTRAIT.isDualScreen)
}
@Test
fun dual_modes_are_dualscreen() {
assert(WindowMode.DUAL_LANDSCAPE.isDualScreen)
assert(WindowMode.DUAL_PORTRAIT.isDualScreen)
}
}

Просмотреть файл

@ -0,0 +1,42 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
package com.microsoft.device.dualscreen.windowstate
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Test
class WindowSizeClassTest {
@Test
fun width_returns_compact() {
assertEquals(WindowSizeClass.COMPACT, getWindowSizeClass(500.dp, Dimension.WIDTH))
}
@Test
fun width_returns_medium() {
assertEquals(WindowSizeClass.MEDIUM, getWindowSizeClass(700.dp, Dimension.WIDTH))
}
@Test
fun width_returns_expanded() {
assertEquals(WindowSizeClass.EXPANDED, getWindowSizeClass(900.dp, Dimension.WIDTH))
}
@Test
fun height_returns_compact() {
assertEquals(WindowSizeClass.COMPACT, getWindowSizeClass(300.dp, Dimension.HEIGHT))
}
@Test
fun height_returns_medium() {
assertEquals(WindowSizeClass.MEDIUM, getWindowSizeClass(700.dp, Dimension.HEIGHT))
}
@Test
fun height_returns_expanded() {
assertEquals(WindowSizeClass.EXPANDED, getWindowSizeClass(1000.dp, Dimension.HEIGHT))
}
}

Просмотреть файл

@ -4,7 +4,7 @@
*/
ext {
gradlePluginVersion = '7.0.3'
gradlePluginVersion = '7.0.4'
kotlinVersion = "1.5.31"
compileSdkVersion = 31
targetSdkVersion = compileSdkVersion
@ -23,7 +23,7 @@ ext {
// AndroidX versions
appCompatVersion = "1.3.0"
ktxCoreVersion = "1.5.0"
windowVersion = "1.0.0-beta03"
windowVersion = "1.0.0-beta04"
androidxDependencies = [
appCompat : "androidx.appcompat:appcompat:$appCompatVersion",
ktxCore : "androidx.core:core-ktx:$ktxCoreVersion",
@ -49,6 +49,8 @@ ext {
androidxTestVersion = "1.4.0"
uiAutomatorVersion = "2.2.0"
espressoVersion = "3.4.0"
junitVersion = "4.12"
mockitoVersion = "4.1.0"
testDependencies = [
androidxTestCore : "androidx.test:core:$androidxTestVersion",
androidxTestRules : "androidx.test:rules:$androidxTestVersion",
@ -59,6 +61,8 @@ ext {
uiAutomator : "androidx.test.uiautomator:uiautomator:$uiAutomatorVersion",
windowTest : "androidx.window:window-testing:$windowVersion",
espressoCore : "androidx.test.espresso:espresso-core:$espressoVersion",
androidJunit : "junit:junit:$junitVersion",
mockitoCore : "org.mockito:mockito-core:$mockitoVersion",
]
// Google dependencies
@ -68,7 +72,7 @@ ext {
]
// Microsoft dependencies
twoPaneLayoutVersion = "1.0.0-alpha09"
twoPaneLayoutVersion = "1.0.0-alpha10"
microsoftDependencies = [
twoPaneLayout: "com.microsoft.device.dualscreen:twopanelayout:$twoPaneLayoutVersion",
]

Просмотреть файл

@ -4,4 +4,4 @@
*/
rootProject.name = "ComposeSamples"
include ':ListDetail', ':CompanionPane', ':DualView', ':ExtendedCanvas', ':ComposeGallery', ':TwoPage', ':NavigationRail', ':TestUtils'
include ':ListDetail', ':CompanionPane', ':DualView', ':ExtendedCanvas', ':ComposeGallery', ':TwoPage', ':NavigationRail', ':TestUtils', ':WindowState'