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
This commit is contained in:
Родитель
a2b4061478
Коммит
b8969f4b37
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -19,3 +19,4 @@ android.useAndroidX=true
|
|||
android.enableJetifier=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
android.disableAutomaticComponentCreation=true
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<IntSize>(2)
|
||||
val childPosition = arrayOfNulls<Offset>(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<IntSize>(2)
|
||||
val childPosition = arrayOfNulls<Offset>(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()
|
||||
}
|
||||
) {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Size>,
|
||||
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<Size>,
|
||||
measurables: List<Measurable>
|
||||
): List<Placeable> {
|
||||
val paneWidth = paneSize.width.toInt()
|
||||
val paneHeight = paneSize.height.toInt()
|
||||
val placeables = emptyList<Placeable>().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)
|
||||
)
|
||||
return measurables.map { it.measure(childConstraints) }
|
||||
|
||||
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<Measurable>,
|
||||
totalWeight: Float,
|
||||
orientation: LayoutOrientation,
|
||||
windowMode: WindowMode,
|
||||
twoPaneParentData: Array<TwoPaneParentData?>
|
||||
): List<Placeable> {
|
||||
val minWidth = constraints.minWidth
|
||||
|
@ -136,14 +144,16 @@ private fun measureTwoPaneProportionally(
|
|||
val weight = parentData.weight
|
||||
var childConstraints: Constraints
|
||||
val ratio = weight / totalWeight
|
||||
childConstraints = if (orientation == LayoutOrientation.Vertical) {
|
||||
childConstraints = when (windowMode) {
|
||||
WindowMode.DUAL_PORTRAIT -> {
|
||||
Constraints(
|
||||
minWidth = (minWidth * ratio).roundToInt(),
|
||||
minHeight = minHeight,
|
||||
maxWidth = (maxWidth * ratio).roundToInt(),
|
||||
maxHeight = maxHeight
|
||||
)
|
||||
} else {
|
||||
}
|
||||
WindowMode.DUAL_LANDSCAPE -> {
|
||||
Constraints(
|
||||
minWidth = minWidth,
|
||||
minHeight = (minHeight * ratio).roundToInt(),
|
||||
|
@ -151,48 +161,56 @@ private fun measureTwoPaneProportionally(
|
|||
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) {
|
||||
when (windowMode) {
|
||||
WindowMode.DUAL_PORTRAIT -> {
|
||||
var xPosition = 0 // for the first pane
|
||||
if (index != 0) { // for the second pane
|
||||
val lastPaneWidth = paneSize.width.toInt()
|
||||
val lastPaneWidth = lastPaneSize.width.roundToInt()
|
||||
val firstPaneWidth = constraints.maxWidth - lastPaneWidth
|
||||
xPosition += firstPaneWidth
|
||||
}
|
||||
placeable.place(x = xPosition, y = 0)
|
||||
} else {
|
||||
}
|
||||
WindowMode.DUAL_LANDSCAPE -> {
|
||||
var yPosition = 0
|
||||
if (index != 0) {
|
||||
val lastPaneHeight = paneSize.height.toInt()
|
||||
val lastPaneHeight = lastPaneSize.height.roundToInt()
|
||||
val firstPaneHeight = constraints.maxHeight - lastPaneHeight
|
||||
yPosition += firstPaneHeight
|
||||
}
|
||||
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<TwoPaneParentData?>,
|
||||
constraints: Constraints,
|
||||
totalWeight: Float
|
||||
) {
|
||||
if (orientation == LayoutOrientation.Vertical) {
|
||||
when (windowMode) {
|
||||
WindowMode.DUAL_PORTRAIT -> {
|
||||
var xPosition = 0 // for the first pane
|
||||
if (index != 0) { // for the second pane
|
||||
val parentData = twoPaneParentData[index]
|
||||
|
@ -202,7 +220,8 @@ private fun Placeable.PlacementScope.placeTwoPaneProportionally(
|
|||
xPosition += firstPaneWidth
|
||||
}
|
||||
placeable.place(x = xPosition, y = 0)
|
||||
} else {
|
||||
}
|
||||
WindowMode.DUAL_LANDSCAPE -> {
|
||||
var yPosition = 0
|
||||
if (index != 0) {
|
||||
val parentData = twoPaneParentData[index]
|
||||
|
@ -213,6 +232,8 @@ private fun Placeable.PlacementScope.placeTwoPaneProportionally(
|
|||
}
|
||||
placeable.place(x = 0, y = yPosition)
|
||||
}
|
||||
else -> throw IllegalStateException("[placeTwoPaneProportionally] Error: single pane window mode ($windowMode) found inside TwoPaneContainer")
|
||||
}
|
||||
}
|
||||
|
||||
private val IntrinsicMeasurable.data: TwoPaneParentData?
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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<MainActivity>()
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
Загрузка…
Ссылка в новой задаче