From b8969f4b37fc36d52da5a18262389929fc318d36 Mon Sep 17 00:00:00 2001 From: Kristen Halper Date: Wed, 9 Feb 2022 16:43:02 -0500 Subject: [PATCH] Update TwoPaneLayout to use WindowState (#13) * Update gradle version * Update dependencies * Update TwoPaneLayout to use WindowState * Update TwoPaneLayout tests * Update sample and sample tests * Make sure all pixel values are converted the same way --- TwoPaneLayout/dependencies.gradle | 27 +-- TwoPaneLayout/gradle.properties | 3 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- TwoPaneLayout/ktlint.gradle | 4 +- TwoPaneLayout/library/build.gradle | 14 +- .../dualscreen/twopanelayout/LayoutTest.kt | 20 ++- .../dualscreen/twopanelayout/TwoPaneTest.kt | 133 ++++++++++----- .../dualscreen/twopanelayout/TwoPaneLayout.kt | 63 +++---- .../twopanelayout/TwoPaneLayoutImpl.kt | 161 ++++++++++-------- .../twopanelayout/screenState/ScreenState.kt | 73 -------- .../screenState/ScreenStateManager.kt | 80 --------- TwoPaneLayout/sample/build.gradle | 8 +- .../dualscreen/twopanelayout/SampleTest.kt | 49 ++++-- .../dualscreen/twopanelayout/MainActivity.kt | 18 +- 14 files changed, 304 insertions(+), 351 deletions(-) delete mode 100644 TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenState.kt delete mode 100644 TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenStateManager.kt diff --git a/TwoPaneLayout/dependencies.gradle b/TwoPaneLayout/dependencies.gradle index 1b70db1..cb3983e 100644 --- a/TwoPaneLayout/dependencies.gradle +++ b/TwoPaneLayout/dependencies.gradle @@ -22,8 +22,8 @@ ext { // ---------------------------------- - gradlePluginVersion = '7.0.3' - kotlinVersion = "1.5.31" + gradlePluginVersion = '7.1.0' + kotlinVersion = "1.6.10" compileSdkVersion = 31 targetSdkVersion = compileSdkVersion minSdkVersion = 23 @@ -44,10 +44,9 @@ ext { ] // AndroidX dependencies - appCompatVersion = "1.3.0" - ktxCoreVersion = "1.5.0" - windowVersion = "1.0.0-beta04" - + appCompatVersion = '1.4.1' + ktxCoreVersion = '1.7.0' + windowVersion = "1.0.0" androidxDependencies = [ appCompat : "androidx.appcompat:appcompat:$appCompatVersion", ktxCore : "androidx.core:core-ktx:$ktxCoreVersion", @@ -55,10 +54,9 @@ ext { ] // Compose dependencies - composeVersion = "1.0.5" + composeVersion = "1.1.0-rc03" activityComposeVersion = "1.4.0" - navigationComposeVersion = "2.4.0-beta02" - + navigationComposeVersion = "2.4.0" composeDependencies = [ composeMaterial : "androidx.compose.material:material:$composeVersion", composeUI : "androidx.compose.ui:ui:$composeVersion", @@ -79,9 +77,16 @@ ext { ] // Google dependencies - materialVersion = "1.5.0-alpha01" - + materialVersion = "1.5.0" googleDependencies = [ material: "com.google.android.material:material:$materialVersion" ] + + // Microsoft dependencies + windowStateVersion = '1.0.0-alpha02' + composeTestingVersion = '1.0.0-alpha01' + microsoftDependencies = [ + windowState : "com.microsoft.device.dualscreen:windowstate:$windowStateVersion", + composeTesting : "com.microsoft.device.dualscreen.testing:testing-compose:$composeTestingVersion", + ] } diff --git a/TwoPaneLayout/gradle.properties b/TwoPaneLayout/gradle.properties index 98bed16..d82cb63 100644 --- a/TwoPaneLayout/gradle.properties +++ b/TwoPaneLayout/gradle.properties @@ -18,4 +18,5 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +android.disableAutomaticComponentCreation=true \ No newline at end of file diff --git a/TwoPaneLayout/gradle/wrapper/gradle-wrapper.properties b/TwoPaneLayout/gradle/wrapper/gradle-wrapper.properties index 0c21bce..79e6fd1 100644 --- a/TwoPaneLayout/gradle/wrapper/gradle-wrapper.properties +++ b/TwoPaneLayout/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Apr 15 16:13:21 PDT 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/TwoPaneLayout/ktlint.gradle b/TwoPaneLayout/ktlint.gradle index 9f8139b..1ef63bf 100644 --- a/TwoPaneLayout/ktlint.gradle +++ b/TwoPaneLayout/ktlint.gradle @@ -18,7 +18,7 @@ dependencies { task ktlint(type: JavaExec, group: "verification") { description = "Check Kotlin code style." classpath = configurations.ktlint - main = "com.pinterest.ktlint.Main" + mainClass.set("com.pinterest.ktlint.Main") args "src/**/*.kt" // to generate report in checkstyle format prepend following args: // "--reporter=plain", "--reporter=checkstyle,output=${buildDir}/ktlint.xml" @@ -28,6 +28,6 @@ task ktlint(type: JavaExec, group: "verification") { task ktlintFormat(type: JavaExec, group: "formatting") { description = "Fix Kotlin code style deviations." classpath = configurations.ktlint - main = "com.pinterest.ktlint.Main" + mainClass.set("com.pinterest.ktlint.Main") args "-F", "src/**/*.kt" } diff --git a/TwoPaneLayout/library/build.gradle b/TwoPaneLayout/library/build.gradle index ee29b92..ccb281e 100644 --- a/TwoPaneLayout/library/build.gradle +++ b/TwoPaneLayout/library/build.gradle @@ -23,6 +23,8 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode rootProject.ext.twoPaneLayoutVersionCode versionName rootProject.ext.twoPaneLayoutVersionName + buildConfigField("int", "VERSION_CODE", "${defaultConfig.versionCode}") + buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -53,24 +55,28 @@ android { composeOptions { kotlinCompilerExtensionVersion composeVersion } - packagingOptions { - exclude 'META-INF/LGPL2.1' - exclude 'META-INF/AL2.0' + resources { + excludes += ['META-INF/LGPL2.1', 'META-INF/AL2.0'] + } } + } dependencies { implementation kotlinDependencies.kotlinStdlib + implementation androidxDependencies.ktxCore implementation androidxDependencies.appCompat - implementation androidxDependencies.window + implementation composeDependencies.composeUI implementation composeDependencies.composeMaterial implementation composeDependencies.composeUITooling implementation composeDependencies.activityCompose implementation composeDependencies.navigationCompose + implementation microsoftDependencies.windowState + androidTestImplementation(testDependencies.androidxTestCore) androidTestImplementation(testDependencies.androidxTestRules) androidTestImplementation(testDependencies.androidxTestRunner) diff --git a/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/LayoutTest.kt b/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/LayoutTest.kt index 1884546..fd3ae86 100644 --- a/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/LayoutTest.kt +++ b/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/LayoutTest.kt @@ -26,12 +26,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewRootForTest import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntSize -import com.microsoft.device.dualscreen.twopanelayout.screenState.ScreenState +import androidx.compose.ui.unit.dp +import com.microsoft.device.dualscreen.windowstate.WindowState import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -98,14 +101,23 @@ open class LayoutTest { @Composable internal fun MockTwoPaneLayout( - screenState: ScreenState, + windowState: WindowState, constraints: Constraints, firstPane: @Composable TwoPaneScope.() -> Unit, secondPane: @Composable TwoPaneScope.() -> Unit ) { + val pane1SizePx: Size + val pane2SizePx: Size + with(LocalDensity.current) { + val pane1SizeDp = windowState.pane1SizeDp() + val pane2SizeDp = windowState.pane2SizeDp() + pane1SizePx = Size(pane1SizeDp.width.dp.toPx(), pane1SizeDp.height.dp.toPx()) + pane2SizePx = Size(pane2SizeDp.width.dp.toPx(), pane2SizeDp.height.dp.toPx()) + } + val measurePolicy = twoPaneMeasurePolicy( - orientation = screenState.orientation, - paneSize = screenState.paneSize, + windowMode = windowState.windowMode, + paneSizes = arrayOf(pane1SizePx, pane2SizePx), mockConstraints = constraints ) Layout( diff --git a/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneTest.kt b/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneTest.kt index b5bfcf9..afc6d9f 100644 --- a/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneTest.kt +++ b/TwoPaneLayout/library/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneTest.kt @@ -6,18 +6,18 @@ package com.microsoft.device.dualscreen.twopanelayout import android.graphics.Rect +import android.graphics.RectF import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.microsoft.device.dualscreen.twopanelayout.screenState.DeviceType -import com.microsoft.device.dualscreen.twopanelayout.screenState.LayoutOrientation -import com.microsoft.device.dualscreen.twopanelayout.screenState.LayoutState -import com.microsoft.device.dualscreen.twopanelayout.screenState.ScreenState +import com.microsoft.device.dualscreen.windowstate.WindowMode +import com.microsoft.device.dualscreen.windowstate.WindowState import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -29,32 +29,55 @@ import kotlin.math.roundToInt @RunWith(AndroidJUnit4::class) class TwoPaneTest : LayoutTest() { - + /** + * Check that isSinglePane returns the correct value for all postures when in TwoPane mode + */ @Test - fun isSinglePaneCheck_withSinglePane() { - val layoutState = LayoutState.Fold + fun isSinglePaneCheck_withTwoPaneMode() { val paneMode = TwoPaneMode.TwoPane - val orientation = LayoutOrientation.Horizontal - val isSinglePane = isSinglePaneLayout(layoutState, paneMode, orientation) - assertTrue(isSinglePane) + + for (windowMode in WindowMode.values()) { + val isSinglePane = isSinglePaneLayout(windowMode, paneMode) + + when { + windowMode.isDualScreen -> assert(!isSinglePane) + else -> assert(isSinglePane) + } + } } + /** + * Check that isSinglePane returns the correct value for all postures when in HorizontalSingle mode + */ @Test fun isSinglePaneCheck_withHorizontalSingleMode() { - val layoutState = LayoutState.Open val paneMode = TwoPaneMode.HorizontalSingle - val orientation = LayoutOrientation.Horizontal - val isSinglePane = isSinglePaneLayout(layoutState, paneMode, orientation) - assertTrue(isSinglePane) + + for (windowMode in WindowMode.values()) { + val isSinglePane = isSinglePaneLayout(windowMode, paneMode) + + when (windowMode) { + WindowMode.DUAL_PORTRAIT -> assert(!isSinglePane) + else -> assert(isSinglePane) + } + } } + /** + * Check that isSinglePane returns the correct value for all postures when in VerticalSingle mode + */ @Test - fun isSinglePaneCheck_withDualPane() { - val layoutState = LayoutState.Open + fun isSinglePaneCheck_withVerticalSingleMode() { val paneMode = TwoPaneMode.VerticalSingle - val orientation = LayoutOrientation.Horizontal - val isSinglePane = isSinglePaneLayout(layoutState, paneMode, orientation) - assertTrue(!isSinglePane) + + for (windowMode in WindowMode.values()) { + val isSinglePane = isSinglePaneLayout(windowMode, paneMode) + + when (windowMode) { + WindowMode.DUAL_LANDSCAPE -> assert(!isSinglePane) + else -> assert(isSinglePane) + } + } } @Test @@ -74,7 +97,7 @@ class TwoPaneTest : LayoutTest() { Modifier .onGloballyPositioned { coordinates -> childSize[0] = coordinates.size - childPosition[0] = coordinates.positionInRoot() + childPosition[0] = coordinates.positionInParent() drawLatch.countDown() } ) {} @@ -85,7 +108,7 @@ class TwoPaneTest : LayoutTest() { Modifier .onGloballyPositioned { coordinates -> childSize[1] = coordinates.size - childPosition[1] = coordinates.positionInRoot() + childPosition[1] = coordinates.positionInParent() drawLatch.countDown() } ) {} @@ -110,28 +133,43 @@ class TwoPaneTest : LayoutTest() { val height = 600 val hingeBounds = Rect(390, 0, 410, 600) val constraints = Constraints(width, width, height, height) - val screenState = ScreenState( - deviceType = DeviceType.Dual, - screenSize = Size(width.toFloat(), height.toFloat()), - hingeBounds = hingeBounds, - orientation = LayoutOrientation.Vertical, - layoutState = LayoutState.Open - ) + var widthDp: Dp + var heightDp: Dp + var hingeBoundsDp: RectF val drawLatch = CountDownLatch(2) val childSize = arrayOfNulls(2) val childPosition = arrayOfNulls(2) activityTestRule.setContent { + with(LocalDensity.current) { + widthDp = width.toDp() + heightDp = height.toDp() + + val left = hingeBounds.left.toDp().value + val top = hingeBounds.top.toDp().value + val right = hingeBounds.right.toDp().value + val bottom = hingeBounds.bottom.toDp().value + + hingeBoundsDp = RectF(left, top, right, bottom) + } + Container(width = width, height = height) { MockTwoPaneLayout( - screenState = screenState, + windowState = WindowState( + hasFold = true, + foldIsHorizontal = false, + foldBoundsDp = hingeBoundsDp, + foldIsSeparating = true, + windowWidthDp = widthDp, + windowHeightDp = heightDp, + ), constraints = constraints, firstPane = { Container( Modifier .onGloballyPositioned { coordinates -> childSize[0] = coordinates.size - childPosition[0] = coordinates.positionInRoot() + childPosition[0] = coordinates.positionInParent() drawLatch.countDown() } ) {} @@ -141,7 +179,7 @@ class TwoPaneTest : LayoutTest() { Modifier .onGloballyPositioned { coordinates -> childSize[1] = coordinates.size - childPosition[1] = coordinates.positionInRoot() + childPosition[1] = coordinates.positionInParent() drawLatch.countDown() } ) {} @@ -163,25 +201,28 @@ class TwoPaneTest : LayoutTest() { @Test fun tablet_withWeight() { - val width = 800 - val height = 1200 - val hingeBounds = Rect() + val width = 3300 + val height = 4000 val constraints = Constraints(width, width, height, height) - val screenState = ScreenState( - deviceType = DeviceType.Big, - screenSize = Size(width.toFloat(), height.toFloat()), - hingeBounds = hingeBounds, - orientation = LayoutOrientation.Horizontal, - layoutState = LayoutState.Open - ) + + var widthDp: Dp + var heightDp: Dp val drawLatch = CountDownLatch(2) val childSize = arrayOfNulls(2) val childPosition = arrayOfNulls(2) activityTestRule.setContent { + with(LocalDensity.current) { + widthDp = width.toDp() + heightDp = height.toDp() + } + Container(width = width, height = height) { MockTwoPaneLayout( - screenState = screenState, + windowState = WindowState( + windowWidthDp = widthDp, + windowHeightDp = heightDp, + ), constraints = constraints, firstPane = { Container( @@ -189,7 +230,7 @@ class TwoPaneTest : LayoutTest() { .weight(.4f) .onGloballyPositioned { coordinates -> childSize[0] = coordinates.size - childPosition[0] = coordinates.positionInRoot() + childPosition[0] = coordinates.positionInParent() drawLatch.countDown() } ) {} @@ -200,7 +241,7 @@ class TwoPaneTest : LayoutTest() { .weight(.6f) .onGloballyPositioned { coordinates -> childSize[1] = coordinates.size - childPosition[1] = coordinates.positionInRoot() + childPosition[1] = coordinates.positionInParent() drawLatch.countDown() } ) {} diff --git a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayout.kt b/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayout.kt index 2fd2b56..ecd7a3f 100644 --- a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayout.kt +++ b/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayout.kt @@ -5,27 +5,24 @@ package com.microsoft.device.dualscreen.twopanelayout -import android.graphics.Rect +import android.app.Activity import androidx.compose.foundation.layout.LayoutScopeMarker import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -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.geometry.Size import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.microsoft.device.dualscreen.twopanelayout.screenState.ConfigScreenState -import com.microsoft.device.dualscreen.twopanelayout.screenState.DeviceType -import com.microsoft.device.dualscreen.twopanelayout.screenState.LayoutOrientation -import com.microsoft.device.dualscreen.twopanelayout.screenState.LayoutState -import com.microsoft.device.dualscreen.twopanelayout.screenState.ScreenState +import com.microsoft.device.dualscreen.windowstate.WindowMode +import com.microsoft.device.dualscreen.windowstate.WindowState +import com.microsoft.device.dualscreen.windowstate.rememberWindowState /** * TwoPaneMode @@ -61,24 +58,10 @@ fun TwoPaneLayout( pane1: @Composable TwoPaneScope.() -> Unit, pane2: @Composable TwoPaneScope.() -> Unit ) { - var screenState by remember { - mutableStateOf( - ScreenState( - deviceType = DeviceType.Single, - screenSize = Size.Zero, - hingeBounds = Rect(), - orientation = LayoutOrientation.Horizontal, - layoutState = LayoutState.Fold - ) - ) - } - ConfigScreenState(onStateChange = { screenState = it }) + // REVISIT: not sure if this cast is safe + val windowState = (LocalContext.current as Activity).rememberWindowState() - val isSinglePane = isSinglePaneLayout( - layoutState = screenState.layoutState, - paneMode = paneMode, - orientation = screenState.orientation - ) + val isSinglePane = isSinglePaneLayout(windowState.windowMode, paneMode) if (isSinglePane) { SinglePaneContainer( @@ -87,7 +70,7 @@ fun TwoPaneLayout( ) } else { TwoPaneContainer( - screenState = screenState, + windowState = windowState, modifier = modifier, pane1 = pane1, pane2 = pane2 @@ -156,14 +139,23 @@ internal fun SinglePaneContainer( */ @Composable private fun TwoPaneContainer( - screenState: ScreenState, + windowState: WindowState, modifier: Modifier, pane1: @Composable TwoPaneScope.() -> Unit, pane2: @Composable TwoPaneScope.() -> Unit ) { + val pane1SizePx: Size + val pane2SizePx: Size + with(LocalDensity.current) { + val pane1SizeDp = windowState.pane1SizeDp() + val pane2SizeDp = windowState.pane2SizeDp() + pane1SizePx = Size(pane1SizeDp.width.dp.toPx(), pane1SizeDp.height.dp.toPx()) + pane2SizePx = Size(pane2SizeDp.width.dp.toPx(), pane2SizeDp.height.dp.toPx()) + } + val measurePolicy = twoPaneMeasurePolicy( - orientation = screenState.orientation, - paneSize = screenState.paneSize, + windowMode = windowState.windowMode, + paneSizes = arrayOf(pane1SizePx, pane2SizePx), ) Layout( content = { @@ -176,13 +168,12 @@ private fun TwoPaneContainer( } internal fun isSinglePaneLayout( - layoutState: LayoutState, + windowMode: WindowMode, paneMode: TwoPaneMode, - orientation: LayoutOrientation ): Boolean { - return layoutState == LayoutState.Fold || - paneMode == TwoPaneMode.VerticalSingle && orientation == LayoutOrientation.Vertical || - paneMode == TwoPaneMode.HorizontalSingle && orientation == LayoutOrientation.Horizontal + return !windowMode.isDualScreen || + paneMode == TwoPaneMode.VerticalSingle && windowMode == WindowMode.DUAL_PORTRAIT || + paneMode == TwoPaneMode.HorizontalSingle && windowMode == WindowMode.DUAL_LANDSCAPE } @LayoutScopeMarker diff --git a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayoutImpl.kt b/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayoutImpl.kt index a596096..7e1acfb 100644 --- a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayoutImpl.kt +++ b/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/TwoPaneLayoutImpl.kt @@ -16,13 +16,13 @@ import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.InspectorValueInfo import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density -import com.microsoft.device.dualscreen.twopanelayout.screenState.LayoutOrientation +import com.microsoft.device.dualscreen.windowstate.WindowMode import kotlin.math.roundToInt @Composable internal fun twoPaneMeasurePolicy( - orientation: LayoutOrientation, - paneSize: Size, + windowMode: WindowMode, + paneSizes: Array, mockConstraints: Constraints = Constraints(0, 0, 0, 0) ): MeasurePolicy { return MeasurePolicy { measurables, constraints -> @@ -53,7 +53,7 @@ internal fun twoPaneMeasurePolicy( placeables = if (maxWeight == 0f || maxWeight * 2 == totalWeight) { measureTwoPaneEqually( constraints = childrenConstraints, - paneSize = paneSize, + paneSizes = paneSizes, measurables = measurables ) } else { @@ -61,7 +61,7 @@ internal fun twoPaneMeasurePolicy( constraints = childrenConstraints, measurables = measurables, totalWeight = totalWeight, - orientation = orientation, + windowMode = windowMode, twoPaneParentData = twoPaneParentData ) } @@ -70,10 +70,10 @@ internal fun twoPaneMeasurePolicy( layout(childrenConstraints.maxWidth, childrenConstraints.maxHeight) { placeables.forEachIndexed { index, placeable -> placeTwoPaneEqually( - orientation = orientation, + windowMode = windowMode, placeable = placeable, index = index, - paneSize = paneSize, + lastPaneSize = paneSizes[1], constraints = childrenConstraints ) } @@ -82,7 +82,7 @@ internal fun twoPaneMeasurePolicy( layout(childrenConstraints.maxWidth, childrenConstraints.maxHeight) { placeables.forEachIndexed { index, placeable -> placeTwoPaneProportionally( - orientation = orientation, + windowMode = windowMode, placeable = placeable, index = index, twoPaneParentData = twoPaneParentData, @@ -95,34 +95,42 @@ internal fun twoPaneMeasurePolicy( } } -/* +/** * to measure the two panes for dual-screen/foldable/large-screen without weight, * or with two equal weight */ private fun measureTwoPaneEqually( constraints: Constraints, - paneSize: Size, + paneSizes: Array, measurables: List ): List { - val paneWidth = paneSize.width.toInt() - val paneHeight = paneSize.height.toInt() - val childConstraints = Constraints( - minWidth = constraints.minWidth.coerceAtMost(paneWidth), - minHeight = constraints.minHeight.coerceAtMost(paneHeight), - maxWidth = constraints.maxWidth.coerceAtMost(paneWidth), - maxHeight = constraints.maxHeight.coerceAtMost(paneHeight) - ) - return measurables.map { it.measure(childConstraints) } + val placeables = emptyList().toMutableList() + + for (i in measurables.indices) { + val paneWidth = paneSizes[i].width.roundToInt() + val paneHeight = paneSizes[i].height.roundToInt() + + val childConstraints = Constraints( + minWidth = constraints.minWidth.coerceAtMost(paneWidth), + minHeight = constraints.minHeight.coerceAtMost(paneHeight), + maxWidth = constraints.maxWidth.coerceAtMost(paneWidth), + maxHeight = constraints.maxHeight.coerceAtMost(paneHeight) + ) + + placeables.add(measurables[i].measure(childConstraints)) + } + + return placeables } -/* +/** * to measure the pane for dual-screen with two non-equal weight */ private fun measureTwoPaneProportionally( constraints: Constraints, measurables: List, totalWeight: Float, - orientation: LayoutOrientation, + windowMode: WindowMode, twoPaneParentData: Array ): List { val minWidth = constraints.minWidth @@ -136,82 +144,95 @@ private fun measureTwoPaneProportionally( val weight = parentData.weight var childConstraints: Constraints val ratio = weight / totalWeight - childConstraints = if (orientation == LayoutOrientation.Vertical) { - Constraints( - minWidth = (minWidth * ratio).roundToInt(), - minHeight = minHeight, - maxWidth = (maxWidth * ratio).roundToInt(), - maxHeight = maxHeight - ) - } else { - Constraints( - minWidth = minWidth, - minHeight = (minHeight * ratio).roundToInt(), - maxWidth = maxWidth, - maxHeight = (maxHeight * ratio).roundToInt() - ) + childConstraints = when (windowMode) { + WindowMode.DUAL_PORTRAIT -> { + Constraints( + minWidth = (minWidth * ratio).roundToInt(), + minHeight = minHeight, + maxWidth = (maxWidth * ratio).roundToInt(), + maxHeight = maxHeight + ) + } + WindowMode.DUAL_LANDSCAPE -> { + Constraints( + minWidth = minWidth, + minHeight = (minHeight * ratio).roundToInt(), + maxWidth = maxWidth, + maxHeight = (maxHeight * ratio).roundToInt() + ) + } + else -> throw IllegalStateException("[measureTwoPaneProportionally] Error: single pane window mode ($windowMode) found inside TwoPaneContainer") } val placeable = measurables[i].measure(childConstraints) + placeables.add(placeable) } return placeables } private fun Placeable.PlacementScope.placeTwoPaneEqually( - orientation: LayoutOrientation, + windowMode: WindowMode, placeable: Placeable, index: Int, - paneSize: Size, + lastPaneSize: Size, constraints: Constraints ) { - if (orientation == LayoutOrientation.Vertical) { - var xPosition = 0 // for the first pane - if (index != 0) { // for the second pane - val lastPaneWidth = paneSize.width.toInt() - val firstPaneWidth = constraints.maxWidth - lastPaneWidth - xPosition += firstPaneWidth + when (windowMode) { + WindowMode.DUAL_PORTRAIT -> { + var xPosition = 0 // for the first pane + if (index != 0) { // for the second pane + val lastPaneWidth = lastPaneSize.width.roundToInt() + val firstPaneWidth = constraints.maxWidth - lastPaneWidth + xPosition += firstPaneWidth + } + placeable.place(x = xPosition, y = 0) } - placeable.place(x = xPosition, y = 0) - } else { - var yPosition = 0 - if (index != 0) { - val lastPaneHeight = paneSize.height.toInt() - val firstPaneHeight = constraints.maxHeight - lastPaneHeight - yPosition += firstPaneHeight + WindowMode.DUAL_LANDSCAPE -> { + var yPosition = 0 + if (index != 0) { + val lastPaneHeight = lastPaneSize.height.roundToInt() + val firstPaneHeight = constraints.maxHeight - lastPaneHeight + yPosition += firstPaneHeight + } + placeable.place(x = 0, y = yPosition) } - placeable.place(x = 0, y = yPosition) + else -> throw IllegalStateException("[placeTwoPaneEqually] Error: single pane window mode ($windowMode) found inside TwoPaneContainer") } } private fun Placeable.PlacementScope.placeTwoPaneProportionally( - orientation: LayoutOrientation, + windowMode: WindowMode, placeable: Placeable, index: Int, twoPaneParentData: Array, constraints: Constraints, totalWeight: Float ) { - if (orientation == LayoutOrientation.Vertical) { - var xPosition = 0 // for the first pane - if (index != 0) { // for the second pane - val parentData = twoPaneParentData[index] - val weight = parentData.weight - val ratio = 1f - (weight / totalWeight) - val firstPaneWidth = (constraints.maxWidth * ratio).roundToInt() - xPosition += firstPaneWidth + when (windowMode) { + WindowMode.DUAL_PORTRAIT -> { + var xPosition = 0 // for the first pane + if (index != 0) { // for the second pane + val parentData = twoPaneParentData[index] + val weight = parentData.weight + val ratio = 1f - (weight / totalWeight) + val firstPaneWidth = (constraints.maxWidth * ratio).roundToInt() + xPosition += firstPaneWidth + } + placeable.place(x = xPosition, y = 0) } - placeable.place(x = xPosition, y = 0) - } else { - var yPosition = 0 - if (index != 0) { - val parentData = twoPaneParentData[index] - val weight = parentData.weight - val ratio = 1f - (weight / totalWeight) - val firstPaneHeight = (constraints.maxHeight * ratio).roundToInt() - yPosition += firstPaneHeight + WindowMode.DUAL_LANDSCAPE -> { + var yPosition = 0 + if (index != 0) { + val parentData = twoPaneParentData[index] + val weight = parentData.weight + val ratio = 1f - (weight / totalWeight) + val firstPaneHeight = (constraints.maxHeight * ratio).roundToInt() + yPosition += firstPaneHeight + } + placeable.place(x = 0, y = yPosition) } - placeable.place(x = 0, y = yPosition) + else -> throw IllegalStateException("[placeTwoPaneProportionally] Error: single pane window mode ($windowMode) found inside TwoPaneContainer") } } diff --git a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenState.kt b/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenState.kt deleted file mode 100644 index e538486..0000000 --- a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenState.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ - -package com.microsoft.device.dualscreen.twopanelayout.screenState - -import android.graphics.Rect -import androidx.compose.ui.geometry.Size - -/** - * LayoutOrientation - * Horizontal, the width of hinge/folding line is bigger than the height, top/bottom - * Vertical the height of hinge/folding line is bigger than the width, left/right - */ -enum class LayoutOrientation { - Horizontal, - Vertical -} - -/** - * LayoutState - * Open, two-pane layout display, it is always "Open" for big-screen device - * Fold single layout display, including single-screen phone, foldable device in folding mode and app in un-spanned mode - */ -enum class LayoutState { - Open, - Fold -} - -/** - * DeviceType - * Single, // regular single-screen device, such as single-screen phone - * Dual, // dual-screen/foldable device, such as Surface Duo device, Samsung Galaxy Fold 2 - * Big // large-screen device, such as tablet - */ -enum class DeviceType { - Single, - Dual, - Big -} - -class ScreenState( - val deviceType: DeviceType, - val screenSize: Size, - var hingeBounds: Rect, - var orientation: LayoutOrientation, - var layoutState: LayoutState -) { - val paneSize: Size - get() { - if (deviceType == DeviceType.Big) { - return if (orientation == LayoutOrientation.Vertical) { - Size(width = screenSize.width / 2, height = screenSize.height) - } else { - Size(width = screenSize.width, height = screenSize.height / 2) - } - } else if (deviceType == DeviceType.Dual) { - return if (orientation == LayoutOrientation.Vertical) { - Size( - width = hingeBounds.left.toFloat(), - height = hingeBounds.height().toFloat() - ) - } else { - Size( - width = hingeBounds.width().toFloat(), - height = hingeBounds.top.toFloat() - ) - } - } - return screenSize - } -} diff --git a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenStateManager.kt b/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenStateManager.kt deleted file mode 100644 index 32dd62e..0000000 --- a/TwoPaneLayout/library/src/main/java/com/microsoft/device/dualscreen/twopanelayout/screenState/ScreenStateManager.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ - -package com.microsoft.device.dualscreen.twopanelayout.screenState - -import android.app.Activity -import android.content.res.Configuration.ORIENTATION_PORTRAIT -import android.graphics.Rect -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.window.layout.FoldingFeature -import androidx.window.layout.WindowInfoTracker -import kotlinx.coroutines.flow.collect - -const val SMALLEST_TABLET_SCREEN_WIDTH_DP = 585 - -@Composable -fun ConfigScreenState(onStateChange: (ScreenState) -> Unit) { - val context = LocalContext.current - val activity = context as Activity - val windowInfoRep = WindowInfoTracker.getOrCreate(context) - - val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp - val isTablet = smallestScreenWidthDp > SMALLEST_TABLET_SCREEN_WIDTH_DP - var deviceType = if (isTablet) DeviceType.Big else DeviceType.Single - var layoutState = if (isTablet) LayoutState.Open else LayoutState.Fold - val screenHeight = LocalConfiguration.current.screenHeightDp * LocalDensity.current.density - val screenWidth = LocalConfiguration.current.screenWidthDp * LocalDensity.current.density - var orientation = orientationMappingFromScreen(LocalConfiguration.current.orientation) - - LaunchedEffect(windowInfoRep) { - windowInfoRep.windowLayoutInfo(activity) - .collect { newLayoutInfo -> - var featureBounds = Rect() - if (newLayoutInfo.displayFeatures.isNotEmpty()) { - val foldingFeature = newLayoutInfo.displayFeatures.first() as FoldingFeature - featureBounds = foldingFeature.bounds - layoutState = if (foldingFeature.isSeparating) LayoutState.Open else LayoutState.Fold - orientation = orientationMappingFromFoldingFeature(foldingFeature.orientation) - deviceType = DeviceType.Dual - } - - val screenState = ScreenState( - deviceType = deviceType, - screenSize = Size(width = screenWidth, height = screenHeight), - hingeBounds = featureBounds, - orientation = orientation, - layoutState = layoutState - ) - onStateChange(screenState) - } - } -} - -private fun orientationMappingFromFoldingFeature(original: FoldingFeature.Orientation): LayoutOrientation { - return if (original == FoldingFeature.Orientation.VERTICAL) { - LayoutOrientation.Vertical - } else { - LayoutOrientation.Horizontal - } -} - -/** - * For tablet or single-screen device - * Portrait orientation will dual-landscape mode, which is treated as using horizontal hinge - * Landscape orientation will be dual-portrait mode, which is treated as using vertical hinge - */ -private fun orientationMappingFromScreen(original: Int): LayoutOrientation { - return if (original == ORIENTATION_PORTRAIT) { - LayoutOrientation.Horizontal - } else { - LayoutOrientation.Vertical - } -} diff --git a/TwoPaneLayout/sample/build.gradle b/TwoPaneLayout/sample/build.gradle index 6f014cf..5bb6308 100644 --- a/TwoPaneLayout/sample/build.gradle +++ b/TwoPaneLayout/sample/build.gradle @@ -36,11 +36,12 @@ android { composeOptions { kotlinCompilerExtensionVersion composeVersion } - packagingOptions { - exclude 'META-INF/LGPL2.1' - exclude 'META-INF/AL2.0' + resources { + excludes += ['META-INF/LGPL2.1', 'META-INF/AL2.0'] + } } + } dependencies { @@ -62,6 +63,7 @@ dependencies { androidTestImplementation(testDependencies.androidxTestRunner) androidTestImplementation(testDependencies.composeUITest) androidTestImplementation(testDependencies.composeJunit) + androidTestImplementation(microsoftDependencies.composeTesting) debugImplementation(testDependencies.composeUITestManifest) } \ No newline at end of file diff --git a/TwoPaneLayout/sample/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/SampleTest.kt b/TwoPaneLayout/sample/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/SampleTest.kt index f5d38c8..17832a8 100644 --- a/TwoPaneLayout/sample/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/SampleTest.kt +++ b/TwoPaneLayout/sample/src/androidTest/java/com/microsoft/device/dualscreen/twopanelayout/SampleTest.kt @@ -9,14 +9,27 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.microsoft.device.dualscreen.testing.createWindowLayoutInfoPublisherRule +import com.microsoft.device.dualscreen.testing.getString +import com.microsoft.device.dualscreen.testing.simulateHorizontalFoldingFeature +import com.microsoft.device.dualscreen.testing.simulateVerticalFoldingFeature import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule class SampleTest { + private val publisherRule = createWindowLayoutInfoPublisherRule() + private val composeTestRule = createAndroidComposeRule() - @get:Rule - val composeTestRule = createAndroidComposeRule() + @get: Rule + val testRule: TestRule + + init { + testRule = RuleChain.outerRule(publisherRule).around(composeTestRule) + RuleChain.outerRule(composeTestRule) + } @Before fun setUp() { @@ -25,25 +38,37 @@ class SampleTest { } } - private val appName = "TwoPaneLayoutSample" - private val firstPaneText = "First pane: TwoPaneLayout is a UI component for Jetpack Compose, which contains the layouts that help you create UI for dual-screen, foldable, and large-screen devices. TwoPaneLayout provides a two-pane layout for use at the top level of a UI. The component will place two panes side-by-side on dual-screen, foldable, and large-screen devices and one pane only on regular single-screen devices. The two panes can be horizontal or vertical, based on the orientation of the device, unless paneMode is configured." - private val secondPaneText = "Second pane: The element layout is based on the order, which means the first element will be placed in the first pane and the second element will be placed in the second pane. The TwoPaneLayout is able to assign children widths according to their weights provided using the TwoPaneScope.weight modifier. When none of its children have weights, the first child element will be prioritized when the app is running on a regular single-screen device, or when foldable and dual-screen devices become folded or unspanned. If the app is spanned or unfolded on foldable and dual-screen, or large-screen devices, the two elements will be laid out equally to take all the display area." - @Test fun app_launches() { - composeTestRule.onNodeWithText(appName).assertIsDisplayed() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.app_name)).assertIsDisplayed() } @Test fun app_canNavigateToSecondPane() { - composeTestRule.onNodeWithText(firstPaneText).performClick() - composeTestRule.onNodeWithText(secondPaneText).assertIsDisplayed() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.first_pane_text)).performClick() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.second_pane_text)).assertIsDisplayed() } @Test fun app_canNavigateToFirstPane() { - composeTestRule.onNodeWithText(firstPaneText).performClick() - composeTestRule.onNodeWithText(secondPaneText).performClick() - composeTestRule.onNodeWithText(firstPaneText).assertIsDisplayed() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.first_pane_text)).performClick() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.second_pane_text)).performClick() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.first_pane_text)).assertIsDisplayed() + } + + @Test + fun app_dualPortrait_showsTwoPanes() { + publisherRule.simulateVerticalFoldingFeature(composeTestRule) + + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.first_pane_text)).assertIsDisplayed() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.second_pane_text)).assertIsDisplayed() + } + + @Test + fun app_dualLandscape_showsOnePane() { + publisherRule.simulateHorizontalFoldingFeature(composeTestRule) + + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.first_pane_text)).assertIsDisplayed() + composeTestRule.onNodeWithText(composeTestRule.getString(R.string.second_pane_text)).assertDoesNotExist() } } diff --git a/TwoPaneLayout/sample/src/main/java/com/microsoft/device/dualscreen/twopanelayout/MainActivity.kt b/TwoPaneLayout/sample/src/main/java/com/microsoft/device/dualscreen/twopanelayout/MainActivity.kt index 3be3b83..368a58a 100644 --- a/TwoPaneLayout/sample/src/main/java/com/microsoft/device/dualscreen/twopanelayout/MainActivity.kt +++ b/TwoPaneLayout/sample/src/main/java/com/microsoft/device/dualscreen/twopanelayout/MainActivity.kt @@ -58,19 +58,21 @@ fun MainPage() { pane1 = { Text( text = stringResource(R.string.first_pane_text), - modifier = Modifier.fillMaxSize().background(color = Color.Cyan) - .clickable { - navigateToPane2() - } + modifier = Modifier + .fillMaxSize() + .background(color = Color.Cyan) + .clickable { navigateToPane2() }, + color = Color.Black ) }, pane2 = { Text( text = stringResource(R.string.second_pane_text), - modifier = Modifier.fillMaxSize().background(color = Color.Magenta) - .clickable { - navigateToPane1() - } + modifier = Modifier + .fillMaxSize() + .background(color = Color.Magenta) + .clickable { navigateToPane1() }, + color = Color.Black ) } )