diff --git a/README.md b/README.md index d4404bfd7..45ee6fe42 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ A library that provides a layout implementing the swipe-to-refresh UX pattern, s ### 🌏 [Web](./web/) A wrapper around WebView for basic WebView support in Jetpack Compose. +### 📜 [Adaptive](./adaptive/) +A library providing a collection of utilities for adaptive layouts. + ### 📐 [Insets](./insets/) (Deprecated) See our [Migration Guide](https://google.github.io/accompanist/insets/) for migrating to Insets in Compose. diff --git a/adaptive/README.md b/adaptive/README.md new file mode 100644 index 000000000..60647889d --- /dev/null +++ b/adaptive/README.md @@ -0,0 +1,21 @@ +# Adaptive utilities for Jetpack Compose + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-adaptive)](https://search.maven.org/search?q=g:com.google.accompanist) + +For more information, visit the documentation: https://google.github.io/accompanist/adaptive + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-adaptive:" +} +``` + +Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. + + [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-adaptive/ \ No newline at end of file diff --git a/adaptive/api/current.api b/adaptive/api/current.api new file mode 100644 index 000000000..1d9b3f34f --- /dev/null +++ b/adaptive/api/current.api @@ -0,0 +1,42 @@ +// Signature format: 4.0 +package com.google.accompanist.adaptive { + + public final class DisplayFeaturesKt { + method @androidx.compose.runtime.Composable public static java.util.List calculateDisplayFeatures(android.app.Activity activity); + } + + @kotlin.jvm.JvmInline public final class FoldAwareConfiguration { + field public static final com.google.accompanist.adaptive.FoldAwareConfiguration.Companion Companion; + } + + public static final class FoldAwareConfiguration.Companion { + method public int getAllFolds(); + method public int getHorizontalFoldsOnly(); + method public int getVerticalFoldsOnly(); + property public final int AllFolds; + property public final int HorizontalFoldsOnly; + property public final int VerticalFoldsOnly; + } + + public final class SplitResult { + ctor public SplitResult(androidx.compose.foundation.gestures.Orientation gapOrientation, androidx.compose.ui.geometry.Rect gapBounds); + method public androidx.compose.ui.geometry.Rect getGapBounds(); + method public androidx.compose.foundation.gestures.Orientation getGapOrientation(); + property public final androidx.compose.ui.geometry.Rect gapBounds; + property public final androidx.compose.foundation.gestures.Orientation gapOrientation; + } + + public final class TwoPaneKt { + method public static com.google.accompanist.adaptive.TwoPaneStrategy HorizontalTwoPaneStrategy(float splitFraction, optional float gapWidth); + method public static com.google.accompanist.adaptive.TwoPaneStrategy HorizontalTwoPaneStrategy(float splitOffset, optional boolean offsetFromStart, optional float gapWidth); + method @androidx.compose.runtime.Composable public static void TwoPane(kotlin.jvm.functions.Function0 first, kotlin.jvm.functions.Function0 second, com.google.accompanist.adaptive.TwoPaneStrategy strategy, java.util.List displayFeatures, optional androidx.compose.ui.Modifier modifier, optional int foldAwareConfiguration); + method public static com.google.accompanist.adaptive.TwoPaneStrategy VerticalTwoPaneStrategy(float splitFraction, optional float gapHeight); + method public static com.google.accompanist.adaptive.TwoPaneStrategy VerticalTwoPaneStrategy(float splitOffset, optional boolean offsetFromTop, optional float gapHeight); + } + + public fun interface TwoPaneStrategy { + method public com.google.accompanist.adaptive.SplitResult calculateSplitResult(androidx.compose.ui.unit.Density density, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.layout.LayoutCoordinates layoutCoordinates); + } + +} + diff --git a/adaptive/build.gradle b/adaptive/build.gradle new file mode 100644 index 000000000..fa867381b --- /dev/null +++ b/adaptive/build.gradle @@ -0,0 +1,119 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +kotlin { + explicitApi() +} + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + // targetSdkVersion has no effect for libraries. This is only used for the test APK + targetSdkVersion 32 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + buildConfig false + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Some of the META-INF files conflict with coroutines-test. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + animationsDisabled true + } + + sourceSets { + test { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + androidTest { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + } +} + +dependencies { + api libs.compose.foundation.foundation + api libs.compose.ui.ui + api libs.androidx.window + + implementation libs.napier + + // ====================== + // Test dependencies + // ====================== + + androidTestImplementation project(':internal-testutils') + testImplementation project(':internal-testutils') + + androidTestImplementation libs.junit + testImplementation libs.junit + + androidTestImplementation libs.truth + testImplementation libs.truth + + androidTestImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.junit4 + + androidTestImplementation libs.compose.ui.test.manifest + testImplementation libs.compose.ui.test.manifest + + androidTestImplementation libs.androidx.test.runner + testImplementation libs.androidx.test.runner + + androidTestImplementation libs.androidx.window.testing + testImplementation libs.androidx.window.testing + + testImplementation libs.robolectric +} + +apply plugin: "com.vanniktech.maven.publish" diff --git a/adaptive/gradle.properties b/adaptive/gradle.properties new file mode 100644 index 000000000..51234ce9a --- /dev/null +++ b/adaptive/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=accompanist-adaptive +POM_NAME=Accompanist Adaptive library +POM_PACKAGING=aar \ No newline at end of file diff --git a/adaptive/src/main/AndroidManifest.xml b/adaptive/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e66addd9e --- /dev/null +++ b/adaptive/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/adaptive/src/main/java/com/google/accompanist/adaptive/DisplayFeatures.kt b/adaptive/src/main/java/com/google/accompanist/adaptive/DisplayFeatures.kt new file mode 100644 index 000000000..f440f816c --- /dev/null +++ b/adaptive/src/main/java/com/google/accompanist/adaptive/DisplayFeatures.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.adaptive + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.window.layout.DisplayFeature +import androidx.window.layout.WindowInfoTracker + +/** + * Calculates the list of [DisplayFeature]s from the given [activity]. + */ +@Composable +public fun calculateDisplayFeatures(activity: Activity): List { + val windowInfoTracker = remember(activity) { WindowInfoTracker.getOrCreate(activity) } + val windowLayoutInfo = remember(windowInfoTracker, activity) { + windowInfoTracker.windowLayoutInfo(activity) + } + + val displayFeatures by produceState(initialValue = emptyList()) { + windowLayoutInfo.collect { info -> + value = info.displayFeatures + } + } + + return displayFeatures +} diff --git a/adaptive/src/main/java/com/google/accompanist/adaptive/TwoPane.kt b/adaptive/src/main/java/com/google/accompanist/adaptive/TwoPane.kt new file mode 100644 index 000000000..9603566f0 --- /dev/null +++ b/adaptive/src/main/java/com/google/accompanist/adaptive/TwoPane.kt @@ -0,0 +1,598 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.adaptive + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toComposeRect +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.constrain +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import kotlin.math.roundToInt + +/** + * A layout that places two different pieces of content defined by the [first] and [second] + * slots where the arrangement, sizes and separation behaviour is controlled by [TwoPaneStrategy]. + * + * [TwoPane] is fold and hinges aware using the provided the [displayFeatures] (which should + * normally be calculated via [calculateDisplayFeatures]). The layout will be adapted to properly + * separate [first] and [second] panes so they don't interfere with hardware hinges (vertical or + * horizontal) as specified in [displayFeatures], or respect folds when needed (for example, when + * foldable is half-folded (90-degree fold AKA tabletop) the split will become on the bend). + * + * To only be aware of folds with a specific orientation, pass in an alternate + * [foldAwareConfiguration] to only adjust for vertical or horizontal folds. + * + * The [TwoPane] layout will always place both [first] and [second], based on the provided + * [strategy] and window environment. If you instead only want to place one or the other, + * that should be controlled at a higher level and not calling [TwoPane] if placing both is not + * desired. + * + * @param first the first content of the layout, a left-most in LTR, a right-most in RTL and + * top-most in a vertical split based on the [SplitResult] of [TwoPaneStrategy.calculateSplitResult] + * @param second the second content of the layout, a right-most in the LTR, a left-most in the RTL + * and the bottom-most in a vertical split based on the [SplitResult] of + * [TwoPaneStrategy.calculateSplitResult] + * @param strategy strategy of the two pane that controls the arrangement of the layout + * @param modifier an optional modifier for the layout + */ +@Composable +public fun TwoPane( + first: @Composable () -> Unit, + second: @Composable () -> Unit, + strategy: TwoPaneStrategy, + displayFeatures: List, + modifier: Modifier = Modifier, + foldAwareConfiguration: FoldAwareConfiguration = FoldAwareConfiguration.AllFolds, +) { + TwoPane( + first = first, + second = second, + strategy = when (foldAwareConfiguration) { + FoldAwareConfiguration.HorizontalFoldsOnly -> { + VerticalTwoPaneStrategy( + displayFeatures = displayFeatures, + defaultStrategy = strategy, + ) + } + FoldAwareConfiguration.VerticalFoldsOnly -> { + HorizontalTwoPaneStrategy( + displayFeatures = displayFeatures, + defaultStrategy = strategy, + ) + } + FoldAwareConfiguration.AllFolds -> { + TwoPaneStrategy( + displayFeatures = displayFeatures, + defaultStrategy = strategy, + ) + } + else -> error("Unknown FoldAware value!") + }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun TwoPane( + first: @Composable () -> Unit, + second: @Composable () -> Unit, + strategy: TwoPaneStrategy, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + Layout( + modifier = modifier.wrapContentSize(), + content = { + Box(Modifier.layoutId("first")) { + first() + } + Box(Modifier.layoutId("second")) { + second() + } + } + ) { measurable, constraints -> + val firstMeasurable = measurable.find { it.layoutId == "first" }!! + val secondMeasurable = measurable.find { it.layoutId == "second" }!! + + layout(constraints.maxWidth, constraints.maxHeight) { + val splitResult = strategy.calculateSplitResult( + density = density, + layoutDirection = layoutDirection, + layoutCoordinates = checkNotNull(coordinates) { + "TwoPane does not support the use of alignment lines!" + } + ) + + val gapOrientation = splitResult.gapOrientation + val gapBounds = splitResult.gapBounds + + val gapLeft = constraints.constrainWidth(gapBounds.left.roundToInt()) + val gapRight = constraints.constrainWidth(gapBounds.right.roundToInt()) + val gapTop = constraints.constrainHeight(gapBounds.top.roundToInt()) + val gapBottom = constraints.constrainHeight(gapBounds.bottom.roundToInt()) + val firstConstraints = + if (gapOrientation == Orientation.Vertical) { + val width = when (layoutDirection) { + LayoutDirection.Ltr -> gapLeft + LayoutDirection.Rtl -> constraints.maxWidth - gapRight + } + + constraints.copy(minWidth = width, maxWidth = width) + } else { + constraints.copy(minHeight = gapTop, maxHeight = gapTop) + } + val secondConstraints = + if (gapOrientation == Orientation.Vertical) { + val width = when (layoutDirection) { + LayoutDirection.Ltr -> constraints.maxWidth - gapRight + LayoutDirection.Rtl -> gapLeft + } + constraints.copy(minWidth = width, maxWidth = width) + } else { + val height = constraints.maxHeight - gapBottom + constraints.copy(minHeight = height, maxHeight = height) + } + val firstPlaceable = firstMeasurable.measure(constraints.constrain(firstConstraints)) + val secondPlaceable = secondMeasurable.measure(constraints.constrain(secondConstraints)) + + firstPlaceable.placeRelative(0, 0) + val detailOffsetX = + if (gapOrientation == Orientation.Vertical) { + constraints.maxWidth - secondPlaceable.width + } else { + 0 + } + val detailOffsetY = + if (gapOrientation == Orientation.Vertical) { + 0 + } else { + constraints.maxHeight - secondPlaceable.height + } + secondPlaceable.placeRelative(detailOffsetX, detailOffsetY) + } + } +} + +/** + * The configuration for which type of folds for a [TwoPane] to automatically avoid. + */ +@JvmInline +public value class FoldAwareConfiguration private constructor(private val value: Int) { + + companion object { + /** + * The [TwoPane] will only be aware of horizontal folds only, splitting the content + * vertically. + */ + val HorizontalFoldsOnly = FoldAwareConfiguration(0) + + /** + * The [TwoPane] will only be aware of vertical folds only, splitting the content + * horizontally. + */ + val VerticalFoldsOnly = FoldAwareConfiguration(1) + + /** + * The [TwoPane] will be aware of both horizontal and vertical folds, splitting the content + * vertically and horizontally respectively. + */ + val AllFolds = FoldAwareConfiguration(2) + } +} + +/** + * Returns the specification for where to place a split in [TwoPane] as a result of + * [TwoPaneStrategy.calculateSplitResult] + */ +public class SplitResult( + + /** + * Whether the gap is vertical or horizontal + */ + public val gapOrientation: Orientation, + + /** + * The bounds that are nether a `start` pane or an `end` pane, but a separation between those + * two. In case width or height is 0 - it means that the gap itself is a 0 width/height, but the + * place within the layout is still defined. + * + * The [gapBounds] should be defined in local bounds to the [TwoPane]. + */ + public val gapBounds: Rect, +) + +/** + * A strategy for configuring the [TwoPane] component, that is responsible for the meta-data + * corresponding to the arrangement of the two panes of the layout. + */ +public fun interface TwoPaneStrategy { + /** + * Calculates the split result in local bounds of the [TwoPane]. + * + * @param density the [Density] for measuring and laying out + * @param layoutDirection the [LayoutDirection] for measuring and laying out + * @param layoutCoordinates the [LayoutCoordinates] of the [TwoPane] + */ + public fun calculateSplitResult( + density: Density, + layoutDirection: LayoutDirection, + layoutCoordinates: LayoutCoordinates + ): SplitResult +} + +/** + * A strategy for configuring the [TwoPane] component, that is responsible for the meta-data + * corresponding to the arrangement of the two panes of the layout. + * + * This strategy can be conditional: If `null` is returned from [calculateSplitResult], then this + * strategy did not produce a split result to use, and a different strategy should be used. + */ +private fun interface ConditionalTwoPaneStrategy { + /** + * Calculates the split result in local bounds of the [TwoPane], or `null` if this strategy + * does not apply. + * + * @param density the [Density] for measuring and laying out + * @param layoutDirection the [LayoutDirection] for measuring and laying out + * @param layoutCoordinates the [LayoutCoordinates] of the [TwoPane] + */ + public fun calculateSplitResult( + density: Density, + layoutDirection: LayoutDirection, + layoutCoordinates: LayoutCoordinates + ): SplitResult? +} + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * If there is a vertical fold, then the gap will be placed along the fold. + * + * Otherwise, the gap will be placed at the given [splitFraction] from start, with the given + * [gapWidth]. + */ +public fun HorizontalTwoPaneStrategy( + splitFraction: Float, + gapWidth: Dp = 0.dp, +): TwoPaneStrategy = FractionHorizontalTwoPaneStrategy( + splitFraction = splitFraction, + gapWidth = gapWidth +) + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * If there is a vertical fold, then the gap will be placed along the fold. + * + * Otherwise, the gap will be placed at [splitOffset] either from the start or end based on + * [offsetFromStart], with the given [gapWidth]. + */ +public fun HorizontalTwoPaneStrategy( + splitOffset: Dp, + offsetFromStart: Boolean = true, + gapWidth: Dp = 0.dp, +): TwoPaneStrategy = FixedOffsetHorizontalTwoPaneStrategy( + splitOffset = splitOffset, + offsetFromStart = offsetFromStart, + gapWidth = gapWidth +) + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * If there is a vertical fold, then the gap will be placed along the fold. + * + * Otherwise, the gap will be placed at the given [splitFraction] from top, with the given + * [gapHeight]. + */ +public fun VerticalTwoPaneStrategy( + splitFraction: Float, + gapHeight: Dp = 0.dp, +): TwoPaneStrategy = FractionVerticalTwoPaneStrategy( + splitFraction = splitFraction, + gapHeight = gapHeight +) + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * If there is a vertical fold, then the gap will be placed along the fold. + * + * Otherwise, the gap will be placed at [splitOffset] either from the top or bottom based on + * [offsetFromTop], with the given [gapHeight]. + */ +public fun VerticalTwoPaneStrategy( + splitOffset: Dp, + offsetFromTop: Boolean = true, + gapHeight: Dp = 0.dp, +): TwoPaneStrategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = splitOffset, + offsetFromTop = offsetFromTop, + gapHeight = gapHeight +) + +/** + * Returns a [TwoPaneStrategy] that will place the slots vertically or horizontally if there is a + * horizontal or vertical fold respectively. + * + * If there is no fold, then the [defaultStrategy] will be used instead. + */ +private fun TwoPaneStrategy( + displayFeatures: List, + defaultStrategy: TwoPaneStrategy, +): TwoPaneStrategy = TwoPaneStrategy( + FoldAwareHorizontalTwoPaneStrategy(displayFeatures), + FoldAwareVerticalTwoPaneStrategy(displayFeatures), + defaultStrategy = defaultStrategy +) + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally if there is a vertical fold. + * + * If there is no fold, then the [defaultStrategy] will be used instead. + */ +private fun HorizontalTwoPaneStrategy( + displayFeatures: List, + defaultStrategy: TwoPaneStrategy, +): TwoPaneStrategy = TwoPaneStrategy( + FoldAwareHorizontalTwoPaneStrategy(displayFeatures), + defaultStrategy = defaultStrategy +) + +/** + * Returns a [TwoPaneStrategy] that will place the slots vertically if there is a horizontal fold. + * + * If there is no fold, then the [defaultStrategy] will be used instead. + */ +private fun VerticalTwoPaneStrategy( + displayFeatures: List, + defaultStrategy: TwoPaneStrategy, +): TwoPaneStrategy = TwoPaneStrategy( + FoldAwareVerticalTwoPaneStrategy(displayFeatures), + defaultStrategy = defaultStrategy +) + +/** + * Returns a composite [TwoPaneStrategy]. + * + * The conditional strategies (if any) will be attempted in order, and their split result used + * if they return one. If none return a split result, then the [defaultStrategy] will be used, + * which guarantees returning a [SplitResult]. + */ +private fun TwoPaneStrategy( + vararg conditionalStrategies: ConditionalTwoPaneStrategy, + defaultStrategy: TwoPaneStrategy +): TwoPaneStrategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> + conditionalStrategies.firstNotNullOfOrNull { conditionalTwoPaneStrategy -> + conditionalTwoPaneStrategy.calculateSplitResult( + density = density, + layoutDirection = layoutDirection, + layoutCoordinates = layoutCoordinates + ) + } ?: defaultStrategy.calculateSplitResult( + density = density, + layoutDirection = layoutDirection, + layoutCoordinates = layoutCoordinates + ) +} + +/** + * Returns a [ConditionalTwoPaneStrategy] that will place the slots horizontally if there is a + * vertical fold, or `null` if there is no fold. + */ +private fun FoldAwareHorizontalTwoPaneStrategy( + displayFeatures: List, +): ConditionalTwoPaneStrategy = ConditionalTwoPaneStrategy { _, _, layoutCoordinates -> + val verticalFold = displayFeatures.find { + it is FoldingFeature && it.orientation == FoldingFeature.Orientation.VERTICAL + } as FoldingFeature? + + if (verticalFold != null && + ( + verticalFold.isSeparating || + verticalFold.occlusionType == FoldingFeature.OcclusionType.FULL + ) && + verticalFold.bounds.toComposeRect().overlaps(layoutCoordinates.boundsInWindow()) + ) { + val foldBounds = verticalFold.bounds.toComposeRect() + SplitResult( + gapOrientation = Orientation.Vertical, + gapBounds = Rect( + layoutCoordinates.windowToLocal(foldBounds.topLeft), + layoutCoordinates.windowToLocal(foldBounds.bottomRight) + ) + ) + } else { + null + } +} + +/** + * Returns a [ConditionalTwoPaneStrategy] that will place the slots vertically if there is a + * horizontal fold, or `null` if there is no fold. + */ +private fun FoldAwareVerticalTwoPaneStrategy( + displayFeatures: List, +): ConditionalTwoPaneStrategy = ConditionalTwoPaneStrategy { _, _, layoutCoordinates -> + val horizontalFold = displayFeatures.find { + it is FoldingFeature && it.orientation == FoldingFeature.Orientation.HORIZONTAL + } as FoldingFeature? + + if (horizontalFold != null && + ( + horizontalFold.isSeparating || + horizontalFold.occlusionType == FoldingFeature.OcclusionType.FULL + ) && + horizontalFold.bounds.toComposeRect().overlaps(layoutCoordinates.boundsInWindow()) + ) { + val foldBounds = horizontalFold.bounds.toComposeRect() + SplitResult( + gapOrientation = Orientation.Horizontal, + gapBounds = Rect( + layoutCoordinates.windowToLocal(foldBounds.topLeft), + layoutCoordinates.windowToLocal(foldBounds.bottomRight) + ) + ) + } else { + null + } +} + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * The gap will be placed at the given [splitFraction] from start, with the given [gapWidth]. + * + * This strategy is _not_ fold aware. + */ +internal fun FractionHorizontalTwoPaneStrategy( + splitFraction: Float, + gapWidth: Dp = 0.dp, +): TwoPaneStrategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> + val splitX = layoutCoordinates.size.width * when (layoutDirection) { + LayoutDirection.Ltr -> splitFraction + LayoutDirection.Rtl -> 1 - splitFraction + } + val splitWidthPixel = with(density) { gapWidth.toPx() } + + SplitResult( + gapOrientation = Orientation.Vertical, + gapBounds = Rect( + left = splitX - splitWidthPixel / 2f, + top = 0f, + right = splitX + splitWidthPixel / 2f, + bottom = layoutCoordinates.size.height.toFloat(), + ) + ) +} + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * The gap will be placed at [splitOffset] either from the start or end based on + * [offsetFromStart], with the given [gapWidth]. + * + * This strategy is _not_ fold aware. + */ +internal fun FixedOffsetHorizontalTwoPaneStrategy( + splitOffset: Dp, + offsetFromStart: Boolean, + gapWidth: Dp = 0.dp, +): TwoPaneStrategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> + val splitOffsetPixel = with(density) { splitOffset.toPx() } + val splitX = when (layoutDirection) { + LayoutDirection.Ltr -> + if (offsetFromStart) { + splitOffsetPixel + } else { + layoutCoordinates.size.width - splitOffsetPixel + } + LayoutDirection.Rtl -> + if (offsetFromStart) { + layoutCoordinates.size.width - splitOffsetPixel + } else { + splitOffsetPixel + } + } + val splitWidthPixel = with(density) { gapWidth.toPx() } + + SplitResult( + gapOrientation = Orientation.Vertical, + gapBounds = Rect( + left = splitX - splitWidthPixel / 2f, + top = 0f, + right = splitX + splitWidthPixel / 2f, + bottom = layoutCoordinates.size.height.toFloat(), + ) + ) +} + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * The split will be placed at the given [splitFraction] from start, with the given [gapHeight]. + * + * This strategy is _not_ fold aware. + */ +internal fun FractionVerticalTwoPaneStrategy( + splitFraction: Float, + gapHeight: Dp = 0.dp, +): TwoPaneStrategy = TwoPaneStrategy { density, _, layoutCoordinates -> + val splitY = layoutCoordinates.size.height * splitFraction + val splitHeightPixel = with(density) { gapHeight.toPx() } + + SplitResult( + gapOrientation = Orientation.Horizontal, + gapBounds = Rect( + left = 0f, + top = splitY - splitHeightPixel / 2f, + right = layoutCoordinates.size.width.toFloat(), + bottom = splitY + splitHeightPixel / 2f, + ) + ) +} + +/** + * Returns a [TwoPaneStrategy] that will place the slots horizontally. + * + * The split will be placed at [splitOffset] either from the top or bottom based on + * [offsetFromTop], with the given [gapHeight]. + * + * This strategy is _not_ fold aware. + */ +internal fun FixedOffsetVerticalTwoPaneStrategy( + splitOffset: Dp, + offsetFromTop: Boolean, + gapHeight: Dp = 0.dp, +): TwoPaneStrategy = TwoPaneStrategy { density, _, layoutCoordinates -> + val splitOffsetPixel = with(density) { splitOffset.toPx() } + val splitY = + if (offsetFromTop) { + splitOffsetPixel + } else { + layoutCoordinates.size.height - splitOffsetPixel + } + val splitHeightPixel = with(density) { gapHeight.toPx() } + + SplitResult( + gapOrientation = Orientation.Horizontal, + gapBounds = Rect( + left = 0f, + top = splitY - splitHeightPixel / 2f, + right = layoutCoordinates.size.width.toFloat(), + bottom = splitY + splitHeightPixel / 2f, + ) + ) +} diff --git a/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/DisplayFeaturesTest.kt b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/DisplayFeaturesTest.kt new file mode 100644 index 000000000..25f4cab72 --- /dev/null +++ b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/DisplayFeaturesTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.adaptive + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowLayoutInfo +import androidx.window.testing.layout.FoldingFeature +import androidx.window.testing.layout.WindowLayoutInfoPublisherRule +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DisplayFeaturesTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val windowLayoutInfoPublisherRule = WindowLayoutInfoPublisherRule() + + @Test + fun empty_folding_features_is_correct() { + lateinit var displayFeatures: List + + composeTestRule.setContent { + displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity) + } + + windowLayoutInfoPublisherRule.overrideWindowLayoutInfo(WindowLayoutInfo(emptyList())) + + composeTestRule.waitForIdle() + + assertThat(displayFeatures).isEmpty() + } + + @Test + fun single_folding_features_is_correct() { + lateinit var displayFeatures: List + + composeTestRule.setContent { + displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity) + } + + val fakeFoldingFeature = FoldingFeature( + activity = composeTestRule.activity, + center = 200, + size = 40, + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.VERTICAL, + ) + + windowLayoutInfoPublisherRule.overrideWindowLayoutInfo( + WindowLayoutInfo( + listOf( + fakeFoldingFeature + ) + ) + ) + + composeTestRule.waitForIdle() + + assertThat(displayFeatures).hasSize(1) + assertThat(displayFeatures[0]).isEqualTo(fakeFoldingFeature) + } + + @Test + fun updating_folding_features_is_correct() { + lateinit var displayFeatures: List + + composeTestRule.setContent { + displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity) + } + + windowLayoutInfoPublisherRule.overrideWindowLayoutInfo(WindowLayoutInfo(emptyList())) + + val fakeFoldingFeature = FoldingFeature( + activity = composeTestRule.activity, + center = 200, + size = 40, + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.VERTICAL, + ) + + windowLayoutInfoPublisherRule.overrideWindowLayoutInfo( + WindowLayoutInfo( + listOf( + fakeFoldingFeature + ) + ) + ) + + composeTestRule.waitForIdle() + + assertThat(displayFeatures).hasSize(1) + assertThat(displayFeatures[0]).isEqualTo(fakeFoldingFeature) + } +} diff --git a/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt new file mode 100644 index 000000000..6419f47af --- /dev/null +++ b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt @@ -0,0 +1,1551 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.adaptive + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toAndroidRect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toIntRect +import androidx.compose.ui.unit.toOffset +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.window.core.ExperimentalWindowApi +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import androidx.window.testing.layout.FoldingFeature +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.roundToInt + +@RunWith(AndroidJUnit4::class) +class TwoPaneTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun fraction_horizontal_renders_correctly_ltr() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionHorizontalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(300.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(300.dp, 0.dp), + DpSize(600.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fraction_horizontal_renders_correctly_rtl() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionHorizontalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(600.dp, 0.dp), + DpSize(300.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(600.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fraction_horizontal_renders_correctly_with_split_width_ltr() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionHorizontalTwoPaneStrategy( + splitFraction = 1f / 3f, + gapWidth = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(268.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(332.dp, 0.dp), + DpSize(568.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fraction_horizontal_renders_correctly_with_split_width_rtl() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionHorizontalTwoPaneStrategy( + splitFraction = 1f / 3f, + gapWidth = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(632.dp, 0.dp), + DpSize(268.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(568.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fraction_vertical_renders_correctly() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionVerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 400.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 400.dp), + DpSize(900.dp, 800.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fraction_vertical_renders_correctly_with_split_height() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionVerticalTwoPaneStrategy( + splitFraction = 1f / 3f, + gapHeight = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 368.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 432.dp), + DpSize(900.dp, 768.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_horizontal_from_start_horizontal_renders_correctly_ltr() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetHorizontalTwoPaneStrategy( + splitOffset = 200.dp, + offsetFromStart = true, + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(200.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(200.dp, 0.dp), + DpSize(700.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_horizontal_from_start_horizontal_renders_correctly_rtl() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetHorizontalTwoPaneStrategy( + splitOffset = 200.dp, + offsetFromStart = true, + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(700.dp, 0.dp), + DpSize(200.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(700.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_horizontal_from_start_renders_correctly_with_split_width_ltr() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetHorizontalTwoPaneStrategy( + splitOffset = 200.dp, + offsetFromStart = true, + gapWidth = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(168.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(232.dp, 0.dp), + DpSize(668.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_horizontal_from_start_renders_correctly_with_split_width_rtl() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetHorizontalTwoPaneStrategy( + splitOffset = 200.dp, + offsetFromStart = true, + gapWidth = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(732.dp, 0.dp), + DpSize(168.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(668.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_vertical_from_top_renders_correctly() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = true + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 300.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 300.dp), + DpSize(900.dp, 900.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_vertical_from_top_renders_correctly_with_split_height() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = true, + gapHeight = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 268.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 332.dp), + DpSize(900.dp, 868.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_vertical_from_bottom_renders_correctly() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = false + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 900.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 900.dp), + DpSize(900.dp, 300.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun fixed_offset_vertical_from_bottom_renders_correctly_with_split_height() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = false, + gapHeight = 64.dp + ), + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 868.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 932.dp), + DpSize(900.dp, 268.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_fallback_when_no_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = emptyList() + ) + } + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 400.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 400.dp), + DpSize(900.dp, 800.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_vertical_placing_when_occluding_horizontal_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 600.dp, + size = 0.dp, + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.HORIZONTAL + ) + ) + ) + } + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 600.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 600.dp), + DpSize(900.dp, 600.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_vertical_placing_when_separating_horizontal_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 600.dp, + size = 60.dp, + state = FoldingFeature.State.FLAT, + orientation = FoldingFeature.Orientation.HORIZONTAL + ) + ) + ) + } + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 570.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 630.dp), + DpSize(900.dp, 570.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_fallback_when_non_occluding_horizontal_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 600.dp, + size = 0.dp, + state = FoldingFeature.State.FLAT, + orientation = FoldingFeature.Orientation.HORIZONTAL + ) + ) + ) + } + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 400.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 400.dp), + DpSize(900.dp, 800.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_horizontal_placing_when_occluding_vertical_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 450.dp, + size = 0.dp, + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.VERTICAL + ) + ) + ) + } + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(450.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(450.dp, 0.dp), + DpSize(450.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_horizontal_placing_when_separating_vertical_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 450.dp, + size = 64.dp, + state = FoldingFeature.State.FLAT, + orientation = FoldingFeature.Orientation.VERTICAL + ) + ) + ) + } + + composeTestRule.setContent { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(418.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(482.dp, 0.dp), + DpSize(418.dp, 1200.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } + + @Test + fun two_pane_strategy_uses_fallback_when_non_occluding_vertical_fold_present() { + lateinit var density: Density + lateinit var twoPaneCoordinates: LayoutCoordinates + lateinit var firstCoordinates: LayoutCoordinates + lateinit var secondCoordinates: LayoutCoordinates + + composeTestRule.setContent { + density = LocalDensity.current + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 450.dp, + size = 0.dp, + state = FoldingFeature.State.FLAT, + orientation = FoldingFeature.Orientation.VERTICAL + ) + ) + ) + } + + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .requiredSize(900.dp, 1200.dp) + .onPlaced { twoPaneCoordinates = it } + ) + } + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 0.dp), + DpSize(900.dp, 400.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), + 0.001f + ) + + compareRectWithTolerance( + with(density) { + DpRect( + DpOffset(0.dp, 400.dp), + DpSize(900.dp, 800.dp) + ).toRect().round().toRect() + }, + twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), + 0.001f + ) + } +} + +private fun compareRectWithTolerance( + expected: Rect, + actual: Rect, + tolerance: Float, +) { + assertThat(actual.left).isWithin(tolerance).of(expected.left) + assertThat(actual.right).isWithin(tolerance).of(expected.right) + assertThat(actual.top).isWithin(tolerance).of(expected.top) + assertThat(actual.bottom).isWithin(tolerance).of(expected.bottom) +} + +/** + * A descriptor of a [FoldingFeature] but with the [center] and [size] specified relative to the + * to the coordinates of the [TwoPane] layout. + */ +private data class LocalFoldingFeature( + val center: Dp, + val size: Dp, + val state: FoldingFeature.State, + val orientation: FoldingFeature.Orientation +) + +/** + * A [List] that lazily constructs the backing delegate list by calling the provided lambda. + */ +private class DelegateList( + listFactory: () -> List +) : List { + val delegate by lazy(listFactory) + override val size: Int get() = delegate.size + override fun get(index: Int): T = delegate[index] + override fun isEmpty(): Boolean = delegate.isEmpty() + override fun iterator(): Iterator = delegate.iterator() + override fun listIterator(): ListIterator = delegate.listIterator() + override fun listIterator(index: Int): ListIterator = delegate.listIterator(index) + override fun subList(fromIndex: Int, toIndex: Int): List = + delegate.subList(fromIndex, toIndex) + override fun lastIndexOf(element: T): Int = delegate.lastIndexOf(element) + override fun indexOf(element: T): Int = delegate.indexOf(element) + override fun containsAll(elements: Collection): Boolean = delegate.containsAll(elements) + override fun contains(element: T): Boolean = delegate.contains(element) +} + +/** + * Folding features are always expressed in window coordinates. + * + * For the sake of testing, however, we want to specify them relative to the [TwoPane] under test. + * + * Therefore, this method takes in a list of [LocalFoldingFeature]s and the [TwoPane] layout info + * in order to map the [LocalFoldingFeature]s into real [FoldingFeature] with the proper window + * coordinates. + * + * In other words, this allows specifying [LocalFoldingFeature]s as if the [TwoPane] layout matches + * the window bounds. + */ +@OptIn(ExperimentalWindowApi::class) +private fun fakeDisplayFeatures( + density: Density, + twoPaneCoordinates: LayoutCoordinates, + localFoldingFeatures: List +): List { + val boundsTopLeftOffset = twoPaneCoordinates.localToWindow( + twoPaneCoordinates.size.toIntRect().topLeft.toOffset() + ) + val boundsBottomRightOffset = twoPaneCoordinates.localToWindow( + twoPaneCoordinates.size.toIntRect().bottomRight.toOffset() + ) + val bounds = Rect( + boundsTopLeftOffset, + boundsBottomRightOffset + ) + + return localFoldingFeatures.map { localFoldingFeature -> + val foldLeft: Float + val foldTop: Float + val foldRight: Float + val foldBottom: Float + + with(density) { + if (localFoldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL) { + foldLeft = 0f + foldTop = + (localFoldingFeature.center - localFoldingFeature.size / 2).toPx() + foldRight = twoPaneCoordinates.size.width.toFloat() + foldBottom = + (localFoldingFeature.center + localFoldingFeature.size / 2).toPx() + } else { + foldLeft = + (localFoldingFeature.center - localFoldingFeature.size / 2).toPx() + foldTop = 0f + foldRight = + (localFoldingFeature.center + localFoldingFeature.size / 2).toPx() + foldBottom = twoPaneCoordinates.size.height.toFloat() + } + } + + val foldTopLeftOffset = twoPaneCoordinates.localToWindow( + Offset(foldLeft, foldTop) + ) + val foldBottomRightOffset = twoPaneCoordinates.localToWindow( + Offset(foldRight, foldBottom) + ) + val foldBounds = Rect( + foldTopLeftOffset, + foldBottomRightOffset, + ) + + val center: Int + val size: Int + + if (localFoldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL) { + center = foldBounds.center.y.roundToInt() + size = foldBounds.height.roundToInt() + } else { + center = foldBounds.center.x.roundToInt() + size = foldBounds.width.roundToInt() + } + + FoldingFeature( + windowBounds = bounds.toAndroidRect(), + center = center, + size = size, + state = localFoldingFeature.state, + orientation = localFoldingFeature.orientation, + ) + } +} + +private fun Rect.round(): IntRect = + IntRect( + left = left.roundToInt(), + top = top.roundToInt(), + right = right.roundToInt(), + bottom = bottom.roundToInt() + ) + +private fun IntRect.toRect(): Rect = + Rect( + left = left.toFloat(), + top = top.toFloat(), + right = right.toFloat(), + bottom = bottom.toFloat() + ) diff --git a/adaptive/src/test/resources/robolectric.properties b/adaptive/src/test/resources/robolectric.properties new file mode 100644 index 000000000..2806eaffa --- /dev/null +++ b/adaptive/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Pin SDK to 30 since Robolectric does not currently support API 31: +# https://github.com/robolectric/robolectric/issues/6635 +sdk=30 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02ea0a317..357cf6564 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ coil = "1.3.2" androidxtest = "1.4.0" androidxnavigation = "2.5.0-rc02" +androidxWindow = "1.0.0" [libraries] compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } @@ -63,6 +64,8 @@ androidx-fragment = "androidx.fragment:fragment-ktx:1.5.1" androidx-dynamicanimation = "androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03" androidx-lifecycle-runtime = "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1" androidx-lifecycle-viewmodel-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" +androidx-window = { module = "androidx.window:window", version.ref = "androidxWindow" } +androidx-window-testing = { module = "androidx.window:window-testing", version.ref = "androidxWindow" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxnavigation" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidxnavigation" } diff --git a/sample/build.gradle b/sample/build.gradle index 1056d92b4..f46a1bfba 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -47,6 +47,7 @@ android { } dependencies { + implementation project(':adaptive') implementation project(':drawablepainter') implementation project(':insets') implementation project(':insets-ui') diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 9d44a7069..98877ba6a 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -413,6 +413,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/BasicTwoPaneSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/BasicTwoPaneSample.kt new file mode 100644 index 000000000..2be94a394 --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/BasicTwoPaneSample.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.sample.adaptive + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.TwoPaneStrategy +import com.google.accompanist.adaptive.VerticalTwoPaneStrategy +import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.google.accompanist.sample.AccompanistSampleTheme + +class BasicTwoPaneSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AccompanistSampleTheme { + val displayFeatures = calculateDisplayFeatures(this) + + TwoPane( + first = { + Card( + modifier = Modifier.padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("First") + } + } + }, + second = { + Card( + modifier = Modifier.padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("Second") + } + } + }, + strategy = TwoPaneStrategy { density, layoutDirection, layoutCoordinates -> + // Split vertically if the height is larger than the width + if (layoutCoordinates.size.height >= layoutCoordinates.size.width) { + VerticalTwoPaneStrategy( + splitFraction = 0.75f + ) + } else { + HorizontalTwoPaneStrategy( + splitFraction = 0.75f + ) + }.calculateSplitResult(density, layoutDirection, layoutCoordinates) + }, + displayFeatures = displayFeatures, + modifier = Modifier.padding(8.dp) + ) + } + } + } +} diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/HorizontalTwoPaneSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/HorizontalTwoPaneSample.kt new file mode 100644 index 000000000..723b07b0c --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/HorizontalTwoPaneSample.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.sample.adaptive + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.accompanist.adaptive.FoldAwareConfiguration +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.google.accompanist.sample.AccompanistSampleTheme + +class HorizontalTwoPaneSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AccompanistSampleTheme { + val displayFeatures = calculateDisplayFeatures(this) + + TwoPane( + first = { + Card( + modifier = Modifier.padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("First") + } + } + }, + second = { + Card( + modifier = Modifier.padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("Second") + } + } + }, + strategy = HorizontalTwoPaneStrategy( + splitFraction = 1f / 3f, + ), + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.padding(8.dp) + ) + } + } + } +} diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/VerticalTwoPaneSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/VerticalTwoPaneSample.kt new file mode 100644 index 000000000..9455a1021 --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/VerticalTwoPaneSample.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.sample.adaptive + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.accompanist.adaptive.FoldAwareConfiguration +import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.VerticalTwoPaneStrategy +import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.google.accompanist.sample.AccompanistSampleTheme + +class VerticalTwoPaneSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AccompanistSampleTheme { + val displayFeatures = calculateDisplayFeatures(this) + + TwoPane( + first = { + Card( + modifier = Modifier.padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("First") + } + } + }, + second = { + Card( + modifier = Modifier.padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("Second") + } + } + }, + strategy = VerticalTwoPaneStrategy( + splitOffset = 200.dp, + ), + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.HorizontalFoldsOnly, + modifier = Modifier.padding(8.dp) + ) + } + } + } +} diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 27d3b6d69..d5747166e 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -63,4 +63,9 @@ Placeholder: Shimmer WebView: Basic + + Adaptive: TwoPane Basic + Adaptive: TwoPane Horizontal + Adaptive: TwoPane Vertical + diff --git a/settings.gradle b/settings.gradle index 017fefa3b..8968085a8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -25,6 +25,7 @@ gradleEnterprise { } } +include ':adaptive' include ':internal-testutils' include ':insets' include ':insets-ui'