diff --git a/adaptive/api/current.api b/adaptive/api/current.api index e9fb10b61..542f42db6 100644 --- a/adaptive/api/current.api +++ b/adaptive/api/current.api @@ -5,6 +5,17 @@ package com.google.accompanist.adaptive { method @androidx.compose.runtime.Composable public static java.util.List calculateDisplayFeatures(android.app.Activity activity); } + public final class FoldAwareColumnKt { + method @androidx.compose.runtime.Composable public static void FoldAwareColumn(java.util.List displayFeatures, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues foldPadding, optional androidx.compose.ui.Alignment.Horizontal horizontalAlignment, kotlin.jvm.functions.Function1 content); + } + + @androidx.compose.foundation.layout.LayoutScopeMarker @androidx.compose.runtime.Immutable public interface FoldAwareColumnScope { + method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier align(androidx.compose.ui.Modifier, androidx.compose.ui.Alignment.Horizontal alignment); + method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier alignBy(androidx.compose.ui.Modifier, androidx.compose.ui.layout.VerticalAlignmentLine alignmentLine); + method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier alignBy(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1 alignmentLineBlock); + method @androidx.compose.runtime.Stable public androidx.compose.ui.Modifier ignoreFold(androidx.compose.ui.Modifier); + } + @kotlin.jvm.JvmInline public final value class FoldAwareConfiguration { field public static final com.google.accompanist.adaptive.FoldAwareConfiguration.Companion Companion; } @@ -18,6 +29,9 @@ package com.google.accompanist.adaptive { property public final int VerticalFoldsOnly; } + public final class RowColumnImplKt { + } + 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(); diff --git a/adaptive/build.gradle.kts b/adaptive/build.gradle.kts index 45d8e440a..651dff981 100644 --- a/adaptive/build.gradle.kts +++ b/adaptive/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(libs.napier) implementation(libs.kotlin.coroutines.android) + implementation(libs.compose.ui.util) // ====================== // Test dependencies diff --git a/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumn.kt b/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumn.kt new file mode 100644 index 000000000..602ea32c3 --- /dev/null +++ b/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumn.kt @@ -0,0 +1,383 @@ +/* + * Copyright 2023 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.util.Range +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.findRootCoordinates +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 androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import kotlin.math.roundToInt + +/** + * A simplified version of [Column] that places children in a fold-aware manner. + * + * The layout starts placing children from the top of the available space. If there is a horizontal + * [separating](https://developer.android.com/reference/kotlin/androidx/window/layout/FoldingFeature#isSeparating()) + * fold present in the window, then the layout will check to see if any children overlap the fold. + * If a child would overlap the fold in its current position, then the layout will increase its + * y coordinate so that the child is now placed below the fold, and any subsequent children will + * also be placed below the fold. + * + * + * @param displayFeatures a list of display features the device currently has + * @param modifier an optional modifier for the layout + * @param foldPadding the optional padding to add around a fold + * @param horizontalAlignment the horizontal alignment of the layout's children. + */ +@Composable +public fun FoldAwareColumn( + displayFeatures: List, + modifier: Modifier = Modifier, + foldPadding: PaddingValues = PaddingValues(), + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable FoldAwareColumnScope.() -> Unit, +) { + Layout( + modifier = modifier, + measurePolicy = foldAwareColumnMeasurePolicy( + verticalArrangement = Arrangement.Top, + horizontalAlignment = horizontalAlignment, + fold = { + // Extract folding feature if horizontal and separating + displayFeatures.find { + it is FoldingFeature && it.orientation == FoldingFeature.Orientation.HORIZONTAL && + it.isSeparating + } as FoldingFeature? + }, + foldPadding = foldPadding, + ), + content = { FoldAwareColumnScopeInstance.content() } + ) +} + +/** + * FoldAwareColumn version of [rowColumnMeasurePolicy] that uses [FoldAwareColumnMeasurementHelper.foldAwarePlaceHelper] + * method instead of [RowColumnMeasurementHelper.placeHelper] + */ +// TODO: change from internal to private once metalava issue is solved https://issuetracker.google.com/issues/271539608 +@Composable +internal fun foldAwareColumnMeasurePolicy( + verticalArrangement: Arrangement.Vertical, + horizontalAlignment: Alignment.Horizontal, + fold: () -> FoldingFeature?, + foldPadding: PaddingValues +) = remember(verticalArrangement, horizontalAlignment, fold, foldPadding) { + + val orientation = LayoutOrientation.Vertical + val arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit = + { totalSize, size, _, density, outPosition -> + with(verticalArrangement) { density.arrange(totalSize, size, outPosition) } + } + val arrangementSpacing = verticalArrangement.spacing + val crossAxisAlignment = CrossAxisAlignment.horizontal(horizontalAlignment) + val crossAxisSize = SizeMode.Wrap + + object : MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + val placeables = arrayOfNulls(measurables.size) + val rowColumnMeasureHelper = + FoldAwareColumnMeasurementHelper( + orientation, + arrangement, + arrangementSpacing, + crossAxisSize, + crossAxisAlignment, + measurables, + placeables + ) + + val measureResult = rowColumnMeasureHelper + .measureWithoutPlacing( + this, + constraints, 0, measurables.size + ) + + val layoutWidth: Int + val layoutHeight: Int + if (orientation == LayoutOrientation.Horizontal) { + layoutWidth = measureResult.mainAxisSize + layoutHeight = measureResult.crossAxisSize + } else { + layoutWidth = measureResult.crossAxisSize + layoutHeight = measureResult.mainAxisSize + } + + // Calculate fold bounds in pixels (including any added fold padding) + val foldBoundsPx = with(density) { + val topPaddingPx = foldPadding.calculateTopPadding().roundToPx() + val bottomPaddingPx = foldPadding.calculateBottomPadding().roundToPx() + + fold()?.bounds?.let { + Rect( + left = it.left.toFloat(), + top = it.top.toFloat() - topPaddingPx, + right = it.right.toFloat(), + bottom = it.bottom.toFloat() + bottomPaddingPx + ) + } + } + + // We only know how much padding is added inside the placement scope, so just add fold height + // and height of the largest child when laying out to cover the maximum possible height + val heightPadding = foldBoundsPx?.let { bounds -> + val largestChildHeight = rowColumnMeasureHelper.placeables.maxOfOrNull { + if ((it?.parentData as? RowColumnParentData)?.ignoreFold == true) { + 0 + } else { + it?.height ?: 0 + } + } ?: 0 + bounds.height.roundToInt() + largestChildHeight + } ?: 0 + val paddedLayoutHeight = layoutHeight + heightPadding + + return layout(layoutWidth, paddedLayoutHeight) { + rowColumnMeasureHelper.foldAwarePlaceHelper( + this, + measureResult, + 0, + layoutDirection, + foldBoundsPx + ) + } + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurables: List, + height: Int + ) = MinIntrinsicWidthMeasureBlock(orientation)( + measurables, + height, + arrangementSpacing.roundToPx() + ) + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurables: List, + width: Int + ) = MinIntrinsicHeightMeasureBlock(orientation)( + measurables, + width, + arrangementSpacing.roundToPx() + ) + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurables: List, + height: Int + ) = MaxIntrinsicWidthMeasureBlock(orientation)( + measurables, + height, + arrangementSpacing.roundToPx() + ) + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurables: List, + width: Int + ) = MaxIntrinsicHeightMeasureBlock(orientation)( + measurables, + width, + arrangementSpacing.roundToPx() + ) + } +} + +/** + * Inherits from [RowColumnMeasurementHelper] to place children in a fold-aware manner + */ +private class FoldAwareColumnMeasurementHelper( + orientation: LayoutOrientation, + arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit, + arrangementSpacing: Dp, + crossAxisSize: SizeMode, + crossAxisAlignment: CrossAxisAlignment, + measurables: List, + placeables: Array +) : RowColumnMeasurementHelper( + orientation, + arrangement, + arrangementSpacing, + crossAxisSize, + crossAxisAlignment, + measurables, + placeables +) { + /** + * Copy of [placeHelper] that has been modified for FoldAwareColumn implementation + */ + @OptIn(ExperimentalComposeUiApi::class) + fun foldAwarePlaceHelper( + placeableScope: Placeable.PlacementScope, + measureResult: RowColumnMeasureHelperResult, + crossAxisOffset: Int, + layoutDirection: LayoutDirection, + foldBoundsPx: Rect? + ) { + with(placeableScope) { + val layoutBounds = coordinates!!.trueBoundsInWindow() + + var placeableY = 0 + + for (i in measureResult.startIndex until measureResult.endIndex) { + val placeable = placeables[i]!! + val mainAxisPositions = measureResult.mainAxisPositions + val crossAxisPosition = getCrossAxisPosition( + placeable, + (measurables[i].parentData as? RowColumnParentData), + measureResult.crossAxisSize, + layoutDirection, + measureResult.beforeCrossAxisAlignmentLine + ) + crossAxisOffset + if (orientation == LayoutOrientation.Horizontal) { + placeable.place( + mainAxisPositions[i - measureResult.startIndex], + crossAxisPosition + ) + } else { + val relativeBounds = Rect( + left = 0f, + top = placeableY.toFloat(), + right = placeable.width.toFloat(), + bottom = (placeableY + placeable.height).toFloat() + ) + val absoluteBounds = + relativeBounds.translate(layoutBounds.left, layoutBounds.top) + + // If placeable overlaps fold, push placeable below + if (foldBoundsPx?.overlapsVertically(absoluteBounds) == true && + (placeable.parentData as? RowColumnParentData)?.ignoreFold != true + ) { + placeableY = (foldBoundsPx.bottom - layoutBounds.top).toInt() + } + + placeable.place(crossAxisPosition, placeableY) + + placeableY += placeable.height + } + } + } + } +} + +/** + * Copy of original [LayoutCoordinates.boundsInWindow], but without the nonzero dimension check. + * + * Instead of returning [Rect.Zero] for a layout with zero width/height, this method will still + * return a Rect with the layout's bounds. + */ +@VisibleForTesting +internal fun LayoutCoordinates.trueBoundsInWindow(): Rect { + val root = findRootCoordinates() + val bounds = boundsInRoot() + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val boundsLeft = bounds.left.coerceIn(0f, rootWidth) + val boundsTop = bounds.top.coerceIn(0f, rootHeight) + val boundsRight = bounds.right.coerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.coerceIn(0f, rootHeight) + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val left = minOf(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x) + val top = minOf(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y) + val right = maxOf(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x) + val bottom = maxOf(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y) + + return Rect(left, top, right, bottom) +} + +/** + * Checks if the vertical ranges of the two Rects overlap (inclusive) + */ +private fun Rect.overlapsVertically(other: Rect): Boolean { + val verticalRange = Range(top, bottom) + val otherVerticalRange = Range(other.top, other.bottom) + return verticalRange.overlaps(otherVerticalRange) +} + +/** + * Inclusive check to see if the given float ranges overlap + */ +private fun Range.overlaps(other: Range): Boolean { + return (lower >= other.lower && lower <= other.upper) || (upper >= other.lower && upper <= other.upper) +} + +/** + * Copy of [RowColumnParentData] that has been modified to include the new ignoreFold field. + */ +internal data class RowColumnParentData( + var weight: Float = 0f, + var fill: Boolean = true, + var crossAxisAlignment: CrossAxisAlignment? = null, + var ignoreFold: Boolean = false +) + +internal class IgnoreFoldModifier( + inspectorInfo: InspectorInfo.() -> Unit +) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { + override fun Density.modifyParentData(parentData: Any?) = + ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { + it.ignoreFold = true + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IgnoreFoldModifier) return false + return true + } + + override fun hashCode(): Int { + return 0 + } + + override fun toString(): String = + "IgnoreFoldModifier(ignoreFold=true)" +} diff --git a/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumnScope.kt b/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumnScope.kt new file mode 100644 index 000000000..5ad371414 --- /dev/null +++ b/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumnScope.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2023 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.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.LayoutScopeMarker +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Measured +import androidx.compose.ui.layout.VerticalAlignmentLine +import androidx.compose.ui.platform.debugInspectorInfo + +/** + * Copy of [ColumnScope] that excludes the weight Modifier attribute. + * + * Also adds a new [ignoreFold] Modifier attribute. + */ +@LayoutScopeMarker +@Immutable +public interface FoldAwareColumnScope { + /** + * Ignore the fold when placing this child within the [FoldAwareColumn]. + */ + @Stable + fun Modifier.ignoreFold(): Modifier + + /** + * Align the element horizontally within the [Column]. This alignment will have priority over + * the [Column]'s `horizontalAlignment` parameter. + * + * Example usage: + * @sample androidx.compose.foundation.layout.samples.SimpleAlignInColumn + */ + @Stable + fun Modifier.align(alignment: Alignment.Horizontal): Modifier + + /** + * Position the element horizontally such that its [alignmentLine] aligns with sibling elements + * also configured to [alignBy]. [alignBy] is a form of [align], + * so both modifiers will not work together if specified for the same layout. + * Within a [Column], all components with [alignBy] will align horizontally using + * the specified [VerticalAlignmentLine]s or values provided using the other + * [alignBy] overload, forming a sibling group. + * At least one element of the sibling group will be placed as it had [Alignment.Start] align + * in [Column], and the alignment of the other siblings will be then determined such that + * the alignment lines coincide. Note that if only one element in a [Column] has the + * [alignBy] modifier specified the element will be positioned + * as if it had [Alignment.Start] align. + * + * Example usage: + * @sample androidx.compose.foundation.layout.samples.SimpleRelativeToSiblingsInColumn + */ + @Stable + fun Modifier.alignBy(alignmentLine: VerticalAlignmentLine): Modifier + + /** + * Position the element horizontally such that the alignment line for the content as + * determined by [alignmentLineBlock] aligns with sibling elements also configured to + * [alignBy]. [alignBy] is a form of [align], so both modifiers + * will not work together if specified for the same layout. + * Within a [Column], all components with [alignBy] will align horizontally using + * the specified [VerticalAlignmentLine]s or values obtained from [alignmentLineBlock], + * forming a sibling group. + * At least one element of the sibling group will be placed as it had [Alignment.Start] align + * in [Column], and the alignment of the other siblings will be then determined such that + * the alignment lines coincide. Note that if only one element in a [Column] has the + * [alignBy] modifier specified the element will be positioned + * as if it had [Alignment.Start] align. + * + * Example usage: + * @sample androidx.compose.foundation.layout.samples.SimpleRelativeToSiblings + */ + @Stable + fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int): Modifier +} + +internal object FoldAwareColumnScopeInstance : FoldAwareColumnScope { + @Stable + override fun Modifier.ignoreFold() = this.then( + IgnoreFoldModifier( + inspectorInfo = debugInspectorInfo { + name = "ignoreFold" + value = true + } + ) + ) + + @Stable + override fun Modifier.align(alignment: Alignment.Horizontal) = this.then( + HorizontalAlignModifier( + horizontal = alignment, + inspectorInfo = debugInspectorInfo { + name = "align" + value = alignment + } + ) + ) + + @Stable + override fun Modifier.alignBy(alignmentLine: VerticalAlignmentLine) = this.then( + SiblingsAlignedModifier.WithAlignmentLine( + alignmentLine = alignmentLine, + inspectorInfo = debugInspectorInfo { + name = "alignBy" + value = alignmentLine + } + ) + ) + + @Stable + override fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int) = this.then( + SiblingsAlignedModifier.WithAlignmentLineBlock( + block = alignmentLineBlock, + inspectorInfo = debugInspectorInfo { + name = "alignBy" + value = alignmentLineBlock + } + ) + ) +} diff --git a/adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnImpl.kt b/adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnImpl.kt new file mode 100644 index 000000000..741d649c2 --- /dev/null +++ b/adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnImpl.kt @@ -0,0 +1,675 @@ +/* + * Copyright 2023 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.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.Measured +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.layout.Placeable +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 androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.util.fastForEach +import com.google.accompanist.adaptive.LayoutOrientation.Horizontal +import com.google.accompanist.adaptive.LayoutOrientation.Vertical +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Copied from: + * RowColumnImpl.kt + * https://android-review.googlesource.com/c/platform/frameworks/support/+/2260390/27/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnImpl.kt + * + * The only changes were updating access modifiers and removing unused code + */ + +/** + * [Row] will be [Horizontal], [Column] is [Vertical]. + */ +internal enum class LayoutOrientation { + Horizontal, + Vertical +} + +/** + * Used to specify the alignment of a layout's children, in cross axis direction. + */ +@Immutable +internal sealed class CrossAxisAlignment { + /** + * Aligns to [size]. If this is a vertical alignment, [layoutDirection] should be + * [LayoutDirection.Ltr]. + * + * @param size The remaining space (total size - content size) in the container. + * @param layoutDirection The layout direction of the content if horizontal or + * [LayoutDirection.Ltr] if vertical. + * @param placeable The item being aligned. + * @param beforeCrossAxisAlignmentLine The space before the cross-axis alignment line if + * an alignment line is being used or 0 if no alignment line is being used. + */ + internal abstract fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int + + /** + * Returns `true` if this is [Relative]. + */ + internal open val isRelative: Boolean + get() = false + + /** + * Returns the alignment line position relative to the left/top of the space or `null` if + * this alignment doesn't rely on alignment lines. + */ + internal open fun calculateAlignmentLinePosition(placeable: Placeable): Int? = null + + companion object { + /** + * Place children such that their center is in the middle of the cross axis. + */ + @Stable + val Center: CrossAxisAlignment = CenterCrossAxisAlignment + + /** + * Place children such that their start edge is aligned to the start edge of the cross + * axis. TODO(popam): Consider rtl directionality. + */ + @Stable + val Start: CrossAxisAlignment = StartCrossAxisAlignment + + /** + * Place children such that their end edge is aligned to the end edge of the cross + * axis. TODO(popam): Consider rtl directionality. + */ + @Stable + val End: CrossAxisAlignment = EndCrossAxisAlignment + + /** + * Align children by their baseline. + */ + fun AlignmentLine(alignmentLine: AlignmentLine): CrossAxisAlignment = + AlignmentLineCrossAxisAlignment(AlignmentLineProvider.Value(alignmentLine)) + + /** + * Align children relative to their siblings using the alignment line provided as a + * parameter using [AlignmentLineProvider]. + */ + internal fun Relative(alignmentLineProvider: AlignmentLineProvider): CrossAxisAlignment = + AlignmentLineCrossAxisAlignment(alignmentLineProvider) + + /** + * Align children with vertical alignment. + */ + internal fun vertical(vertical: Alignment.Vertical): CrossAxisAlignment = + VerticalCrossAxisAlignment(vertical) + + /** + * Align children with horizontal alignment. + */ + internal fun horizontal(horizontal: Alignment.Horizontal): CrossAxisAlignment = + HorizontalCrossAxisAlignment(horizontal) + } + + private object CenterCrossAxisAlignment : CrossAxisAlignment() { + override fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int { + return size / 2 + } + } + + private object StartCrossAxisAlignment : CrossAxisAlignment() { + override fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int { + return if (layoutDirection == LayoutDirection.Ltr) 0 else size + } + } + + private object EndCrossAxisAlignment : CrossAxisAlignment() { + override fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int { + return if (layoutDirection == LayoutDirection.Ltr) size else 0 + } + } + + private class AlignmentLineCrossAxisAlignment( + val alignmentLineProvider: AlignmentLineProvider + ) : CrossAxisAlignment() { + override val isRelative: Boolean + get() = true + + override fun calculateAlignmentLinePosition(placeable: Placeable): Int { + return alignmentLineProvider.calculateAlignmentLinePosition(placeable) + } + + override fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int { + val alignmentLinePosition = + alignmentLineProvider.calculateAlignmentLinePosition(placeable) + return if (alignmentLinePosition != AlignmentLine.Unspecified) { + val line = beforeCrossAxisAlignmentLine - alignmentLinePosition + if (layoutDirection == LayoutDirection.Rtl) { + size - line + } else { + line + } + } else { + 0 + } + } + } + + private class VerticalCrossAxisAlignment( + val vertical: Alignment.Vertical + ) : CrossAxisAlignment() { + override fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int { + return vertical.align(0, size) + } + } + + private class HorizontalCrossAxisAlignment( + val horizontal: Alignment.Horizontal + ) : CrossAxisAlignment() { + override fun align( + size: Int, + layoutDirection: LayoutDirection, + placeable: Placeable, + beforeCrossAxisAlignmentLine: Int + ): Int { + return horizontal.align(0, size, layoutDirection) + } + } +} + +/** + * Box [Constraints], but which abstract away width and height in favor of main axis and cross axis. + */ +internal data class OrientationIndependentConstraints( + val mainAxisMin: Int, + val mainAxisMax: Int, + val crossAxisMin: Int, + val crossAxisMax: Int +) { + constructor(c: Constraints, orientation: LayoutOrientation) : this( + if (orientation === LayoutOrientation.Horizontal) c.minWidth else c.minHeight, + if (orientation === LayoutOrientation.Horizontal) c.maxWidth else c.maxHeight, + if (orientation === LayoutOrientation.Horizontal) c.minHeight else c.minWidth, + if (orientation === LayoutOrientation.Horizontal) c.maxHeight else c.maxWidth + ) + + // Creates a new instance with the same main axis constraints and maximum tight cross axis. + fun stretchCrossAxis() = OrientationIndependentConstraints( + mainAxisMin, + mainAxisMax, + if (crossAxisMax != Constraints.Infinity) crossAxisMax else crossAxisMin, + crossAxisMax + ) + + // Given an orientation, resolves the current instance to traditional constraints. + fun toBoxConstraints(orientation: LayoutOrientation) = + if (orientation === LayoutOrientation.Horizontal) { + Constraints(mainAxisMin, mainAxisMax, crossAxisMin, crossAxisMax) + } else { + Constraints(crossAxisMin, crossAxisMax, mainAxisMin, mainAxisMax) + } + + // Given an orientation, resolves the max width constraint this instance represents. + fun maxWidth(orientation: LayoutOrientation) = + if (orientation === LayoutOrientation.Horizontal) { + mainAxisMax + } else { + crossAxisMax + } + + // Given an orientation, resolves the max height constraint this instance represents. + fun maxHeight(orientation: LayoutOrientation) = + if (orientation === LayoutOrientation.Horizontal) { + crossAxisMax + } else { + mainAxisMax + } +} + +internal val IntrinsicMeasurable.rowColumnParentData: RowColumnParentData? + get() = parentData as? RowColumnParentData + +internal val RowColumnParentData?.weight: Float + get() = this?.weight ?: 0f + +internal val RowColumnParentData?.fill: Boolean + get() = this?.fill ?: true + +internal val RowColumnParentData?.crossAxisAlignment: CrossAxisAlignment? + get() = this?.crossAxisAlignment + +internal val RowColumnParentData?.isRelative: Boolean + get() = this.crossAxisAlignment?.isRelative ?: false + +internal fun MinIntrinsicWidthMeasureBlock(orientation: LayoutOrientation) = + if (orientation == LayoutOrientation.Horizontal) { + IntrinsicMeasureBlocks.HorizontalMinWidth + } else { + IntrinsicMeasureBlocks.VerticalMinWidth + } + +internal fun MinIntrinsicHeightMeasureBlock(orientation: LayoutOrientation) = + if (orientation == LayoutOrientation.Horizontal) { + IntrinsicMeasureBlocks.HorizontalMinHeight + } else { + IntrinsicMeasureBlocks.VerticalMinHeight + } + +internal fun MaxIntrinsicWidthMeasureBlock(orientation: LayoutOrientation) = + if (orientation == LayoutOrientation.Horizontal) { + IntrinsicMeasureBlocks.HorizontalMaxWidth + } else { + IntrinsicMeasureBlocks.VerticalMaxWidth + } + +internal fun MaxIntrinsicHeightMeasureBlock(orientation: LayoutOrientation) = + if (orientation == LayoutOrientation.Horizontal) { + IntrinsicMeasureBlocks.HorizontalMaxHeight + } else { + IntrinsicMeasureBlocks.VerticalMaxHeight + } + +internal object IntrinsicMeasureBlocks { + val HorizontalMinWidth: (List, Int, Int) -> Int = + { measurables, availableHeight, mainAxisSpacing -> + intrinsicSize( + measurables, + { h -> minIntrinsicWidth(h) }, + { w -> maxIntrinsicHeight(w) }, + availableHeight, + mainAxisSpacing, + LayoutOrientation.Horizontal, + LayoutOrientation.Horizontal + ) + } + val VerticalMinWidth: (List, Int, Int) -> Int = + { measurables, availableHeight, mainAxisSpacing -> + intrinsicSize( + measurables, + { h -> minIntrinsicWidth(h) }, + { w -> maxIntrinsicHeight(w) }, + availableHeight, + mainAxisSpacing, + LayoutOrientation.Vertical, + LayoutOrientation.Horizontal + ) + } + val HorizontalMinHeight: (List, Int, Int) -> Int = + { measurables, availableWidth, mainAxisSpacing -> + intrinsicSize( + measurables, + { w -> minIntrinsicHeight(w) }, + { h -> maxIntrinsicWidth(h) }, + availableWidth, + mainAxisSpacing, + LayoutOrientation.Horizontal, + LayoutOrientation.Vertical + ) + } + val VerticalMinHeight: (List, Int, Int) -> Int = + { measurables, availableWidth, mainAxisSpacing -> + intrinsicSize( + measurables, + { w -> minIntrinsicHeight(w) }, + { h -> maxIntrinsicWidth(h) }, + availableWidth, + mainAxisSpacing, + LayoutOrientation.Vertical, + LayoutOrientation.Vertical + ) + } + val HorizontalMaxWidth: (List, Int, Int) -> Int = + { measurables, availableHeight, mainAxisSpacing -> + intrinsicSize( + measurables, + { h -> maxIntrinsicWidth(h) }, + { w -> maxIntrinsicHeight(w) }, + availableHeight, + mainAxisSpacing, + LayoutOrientation.Horizontal, + LayoutOrientation.Horizontal + ) + } + val VerticalMaxWidth: (List, Int, Int) -> Int = + { measurables, availableHeight, mainAxisSpacing -> + intrinsicSize( + measurables, + { h -> maxIntrinsicWidth(h) }, + { w -> maxIntrinsicHeight(w) }, + availableHeight, + mainAxisSpacing, + LayoutOrientation.Vertical, + LayoutOrientation.Horizontal + ) + } + val HorizontalMaxHeight: (List, Int, Int) -> Int = + { measurables, availableWidth, mainAxisSpacing -> + intrinsicSize( + measurables, + { w -> maxIntrinsicHeight(w) }, + { h -> maxIntrinsicWidth(h) }, + availableWidth, + mainAxisSpacing, + LayoutOrientation.Horizontal, + LayoutOrientation.Vertical + ) + } + val VerticalMaxHeight: (List, Int, Int) -> Int = + { measurables, availableWidth, mainAxisSpacing -> + intrinsicSize( + measurables, + { w -> maxIntrinsicHeight(w) }, + { h -> maxIntrinsicWidth(h) }, + availableWidth, + mainAxisSpacing, + LayoutOrientation.Vertical, + LayoutOrientation.Vertical + ) + } +} + +private fun intrinsicSize( + children: List, + intrinsicMainSize: IntrinsicMeasurable.(Int) -> Int, + intrinsicCrossSize: IntrinsicMeasurable.(Int) -> Int, + crossAxisAvailable: Int, + mainAxisSpacing: Int, + layoutOrientation: LayoutOrientation, + intrinsicOrientation: LayoutOrientation +) = if (layoutOrientation == intrinsicOrientation) { + intrinsicMainAxisSize(children, intrinsicMainSize, crossAxisAvailable, mainAxisSpacing) +} else { + intrinsicCrossAxisSize( + children, + intrinsicCrossSize, + intrinsicMainSize, + crossAxisAvailable, + mainAxisSpacing + ) +} + +private fun intrinsicMainAxisSize( + children: List, + mainAxisSize: IntrinsicMeasurable.(Int) -> Int, + crossAxisAvailable: Int, + mainAxisSpacing: Int +): Int { + var weightUnitSpace = 0 + var fixedSpace = 0 + var totalWeight = 0f + children.fastForEach { child -> + val weight = child.rowColumnParentData.weight + val size = child.mainAxisSize(crossAxisAvailable) + if (weight == 0f) { + fixedSpace += size + } else if (weight > 0f) { + totalWeight += weight + weightUnitSpace = max(weightUnitSpace, (size / weight).roundToInt()) + } + } + return (weightUnitSpace * totalWeight).roundToInt() + fixedSpace + + (children.size - 1) * mainAxisSpacing +} + +private fun intrinsicCrossAxisSize( + children: List, + mainAxisSize: IntrinsicMeasurable.(Int) -> Int, + crossAxisSize: IntrinsicMeasurable.(Int) -> Int, + mainAxisAvailable: Int, + mainAxisSpacing: Int +): Int { + var fixedSpace = min((children.size - 1) * mainAxisSpacing, mainAxisAvailable) + var crossAxisMax = 0 + var totalWeight = 0f + children.fastForEach { child -> + val weight = child.rowColumnParentData.weight + if (weight == 0f) { + // Ask the child how much main axis space it wants to occupy. This cannot be more + // than the remaining available space. + val mainAxisSpace = min( + child.mainAxisSize(Constraints.Infinity), + mainAxisAvailable - fixedSpace + ) + fixedSpace += mainAxisSpace + // Now that the assigned main axis space is known, ask about the cross axis space. + crossAxisMax = max(crossAxisMax, child.crossAxisSize(mainAxisSpace)) + } else if (weight > 0f) { + totalWeight += weight + } + } + + // For weighted children, calculate how much main axis space weight=1 would represent. + val weightUnitSpace = if (totalWeight == 0f) { + 0 + } else if (mainAxisAvailable == Constraints.Infinity) { + Constraints.Infinity + } else { + (max(mainAxisAvailable - fixedSpace, 0) / totalWeight).roundToInt() + } + + children.fastForEach { child -> + val weight = child.rowColumnParentData.weight + // Now the main axis for weighted children is known, so ask about the cross axis space. + if (weight > 0f) { + crossAxisMax = max( + crossAxisMax, + child.crossAxisSize( + if (weightUnitSpace != Constraints.Infinity) { + (weightUnitSpace * weight).roundToInt() + } else { + Constraints.Infinity + } + ) + ) + } + } + return crossAxisMax +} + +internal class LayoutWeightImpl( + val weight: Float, + val fill: Boolean, + inspectorInfo: InspectorInfo.() -> Unit +) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { + override fun Density.modifyParentData(parentData: Any?) = + ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { + it.weight = weight + it.fill = fill + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherModifier = other as? LayoutWeightImpl ?: return false + return weight == otherModifier.weight && + fill == otherModifier.fill + } + + override fun hashCode(): Int { + var result = weight.hashCode() + result = 31 * result + fill.hashCode() + return result + } + + override fun toString(): String = + "LayoutWeightImpl(weight=$weight, fill=$fill)" +} + +internal sealed class SiblingsAlignedModifier( + inspectorInfo: InspectorInfo.() -> Unit +) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { + abstract override fun Density.modifyParentData(parentData: Any?): Any? + + internal class WithAlignmentLineBlock( + val block: (Measured) -> Int, + inspectorInfo: InspectorInfo.() -> Unit + ) : SiblingsAlignedModifier(inspectorInfo) { + override fun Density.modifyParentData(parentData: Any?): Any { + return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { + it.crossAxisAlignment = + CrossAxisAlignment.Relative(AlignmentLineProvider.Block(block)) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherModifier = other as? WithAlignmentLineBlock ?: return false + return block == otherModifier.block + } + + override fun hashCode(): Int = block.hashCode() + + override fun toString(): String = "WithAlignmentLineBlock(block=$block)" + } + + internal class WithAlignmentLine( + val alignmentLine: AlignmentLine, + inspectorInfo: InspectorInfo.() -> Unit + ) : SiblingsAlignedModifier(inspectorInfo) { + override fun Density.modifyParentData(parentData: Any?): Any { + return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { + it.crossAxisAlignment = + CrossAxisAlignment.Relative(AlignmentLineProvider.Value(alignmentLine)) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherModifier = other as? WithAlignmentLine ?: return false + return alignmentLine == otherModifier.alignmentLine + } + + override fun hashCode(): Int = alignmentLine.hashCode() + + override fun toString(): String = "WithAlignmentLine(line=$alignmentLine)" + } +} + +internal class HorizontalAlignModifier( + val horizontal: Alignment.Horizontal, + inspectorInfo: InspectorInfo.() -> Unit +) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { + override fun Density.modifyParentData(parentData: Any?): RowColumnParentData { + return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { + it.crossAxisAlignment = CrossAxisAlignment.horizontal(horizontal) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherModifier = other as? HorizontalAlignModifier ?: return false + return horizontal == otherModifier.horizontal + } + + override fun hashCode(): Int = horizontal.hashCode() + + override fun toString(): String = + "HorizontalAlignModifier(horizontal=$horizontal)" +} + +internal class VerticalAlignModifier( + val vertical: Alignment.Vertical, + inspectorInfo: InspectorInfo.() -> Unit +) : ParentDataModifier, InspectorValueInfo(inspectorInfo) { + override fun Density.modifyParentData(parentData: Any?): RowColumnParentData { + return ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also { + it.crossAxisAlignment = CrossAxisAlignment.vertical(vertical) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherModifier = other as? VerticalAlignModifier ?: return false + return vertical == otherModifier.vertical + } + + override fun hashCode(): Int = vertical.hashCode() + + override fun toString(): String = + "VerticalAlignModifier(vertical=$vertical)" +} + +/** + * Provides the alignment line. + */ +internal sealed class AlignmentLineProvider { + abstract fun calculateAlignmentLinePosition(placeable: Placeable): Int + data class Block(val lineProviderBlock: (Measured) -> Int) : AlignmentLineProvider() { + override fun calculateAlignmentLinePosition( + placeable: Placeable + ): Int { + return lineProviderBlock(placeable) + } + } + + data class Value(val alignmentLine: AlignmentLine) : AlignmentLineProvider() { + override fun calculateAlignmentLinePosition(placeable: Placeable): Int { + return placeable[alignmentLine] + } + } +} + +/** + * Used to specify how a layout chooses its own size when multiple behaviors are possible. + */ +// TODO(popam): remove this when Flow is reworked +internal enum class SizeMode { + /** + * Minimize the amount of free space by wrapping the children, + * subject to the incoming layout constraints. + */ + Wrap, + + /** + * Maximize the amount of free space by expanding to fill the available space, + * subject to the incoming layout constraints. + */ + Expand +} diff --git a/adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnMeasurementHelper.kt b/adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnMeasurementHelper.kt new file mode 100644 index 000000000..dcd3d5a9c --- /dev/null +++ b/adaptive/src/main/java/com/google/accompanist/adaptive/RowColumnMeasurementHelper.kt @@ -0,0 +1,335 @@ +/* + * Copyright 2023 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.ui.layout.AlignmentLine +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sign + +/** + * Copied from: + * RowColumnMeasurementHelper.kt + * https://android-review.googlesource.com/c/platform/frameworks/support/+/2260390/27/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurementHelper.kt + * + * The only changes were updating access modifiers and making RowColumnMeasurementHelper an open class + */ + +/** + * This is a data class that holds the determined width, height of a row, + * and information on how to retrieve main axis and cross axis positions. + */ +internal class RowColumnMeasureHelperResult( + val crossAxisSize: Int, + val mainAxisSize: Int, + val startIndex: Int, + val endIndex: Int, + val beforeCrossAxisAlignmentLine: Int, + val mainAxisPositions: IntArray, +) + +/** + * RowColumnMeasurementHelper + * Measures the row and column without placing, useful for reusing row/column logic + */ +internal open class RowColumnMeasurementHelper( + val orientation: LayoutOrientation, + val arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit, + val arrangementSpacing: Dp, + val crossAxisSize: SizeMode, + val crossAxisAlignment: CrossAxisAlignment, + val measurables: List, + val placeables: Array +) { + + private val rowColumnParentData = Array(measurables.size) { + measurables[it].rowColumnParentData + } + + fun Placeable.mainAxisSize() = + if (orientation == LayoutOrientation.Horizontal) width else height + + fun Placeable.crossAxisSize() = + if (orientation == LayoutOrientation.Horizontal) height else width + + /** + * Measures the row and column without placing, useful for reusing row/column logic + * + * @param measureScope The measure scope to retrieve density + * @param constraints The desired constraints for the startIndex and endIndex + * can hold null items if not measured. + * @param startIndex The startIndex (inclusive) when examining measurables, placeable + * and parentData + * @param endIndex The ending index (exclusive) when examinning measurable, placeable + * and parentData + */ + fun measureWithoutPlacing( + measureScope: MeasureScope, + constraints: Constraints, + startIndex: Int, + endIndex: Int + ): RowColumnMeasureHelperResult { + @Suppress("NAME_SHADOWING") + val constraints = OrientationIndependentConstraints(constraints, orientation) + val arrangementSpacingPx = with(measureScope) { + arrangementSpacing.roundToPx() + } + + var totalWeight = 0f + var fixedSpace = 0 + var crossAxisSpace = 0 + var weightChildrenCount = 0 + + var anyAlignBy = false + val subSize = endIndex - startIndex + + // First measure children with zero weight. + var spaceAfterLastNoWeight = 0 + for (i in startIndex until endIndex) { + val child = measurables[i] + val parentData = rowColumnParentData[i] + val weight = parentData.weight + + if (weight > 0f) { + totalWeight += weight + ++weightChildrenCount + } else { + val mainAxisMax = constraints.mainAxisMax + val placeable = placeables[i] ?: child.measure( + // Ask for preferred main axis size. + constraints.copy( + mainAxisMin = 0, + mainAxisMax = if (mainAxisMax == Constraints.Infinity) { + Constraints.Infinity + } else { + mainAxisMax - fixedSpace + }, + crossAxisMin = 0 + ).toBoxConstraints(orientation) + ) + spaceAfterLastNoWeight = min( + arrangementSpacingPx, + mainAxisMax - fixedSpace - placeable.mainAxisSize() + ) + fixedSpace += placeable.mainAxisSize() + spaceAfterLastNoWeight + crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize()) + anyAlignBy = anyAlignBy || parentData.isRelative + placeables[i] = placeable + } + } + + var weightedSpace = 0 + if (weightChildrenCount == 0) { + // fixedSpace contains an extra spacing after the last non-weight child. + fixedSpace -= spaceAfterLastNoWeight + } else { + // Measure the rest according to their weights in the remaining main axis space. + val targetSpace = + if (totalWeight > 0f && constraints.mainAxisMax != Constraints.Infinity) { + constraints.mainAxisMax + } else { + constraints.mainAxisMin + } + val remainingToTarget = + targetSpace - fixedSpace - arrangementSpacingPx * (weightChildrenCount - 1) + + val weightUnitSpace = if (totalWeight > 0) remainingToTarget / totalWeight else 0f + var remainder = remainingToTarget - (startIndex until endIndex).sumOf { + (weightUnitSpace * rowColumnParentData[it].weight).roundToInt() + } + + for (i in startIndex until endIndex) { + if (placeables[i] == null) { + val child = measurables[i] + val parentData = rowColumnParentData[i] + val weight = parentData.weight + check(weight > 0) { "All weights <= 0 should have placeables" } + // After the weightUnitSpace rounding, the total space going to be occupied + // can be smaller or larger than remainingToTarget. Here we distribute the + // loss or gain remainder evenly to the first children. + val remainderUnit = remainder.sign + remainder -= remainderUnit + val childMainAxisSize = max( + 0, + (weightUnitSpace * weight).roundToInt() + remainderUnit + ) + val placeable = child.measure( + OrientationIndependentConstraints( + if (parentData.fill && childMainAxisSize != Constraints.Infinity) { + childMainAxisSize + } else { + 0 + }, + childMainAxisSize, + 0, + constraints.crossAxisMax + ).toBoxConstraints(orientation) + ) + weightedSpace += placeable.mainAxisSize() + crossAxisSpace = max(crossAxisSpace, placeable.crossAxisSize()) + anyAlignBy = anyAlignBy || parentData.isRelative + placeables[i] = placeable + } + } + weightedSpace = (weightedSpace + arrangementSpacingPx * (weightChildrenCount - 1)) + .coerceAtMost(constraints.mainAxisMax - fixedSpace) + } + + var beforeCrossAxisAlignmentLine = 0 + var afterCrossAxisAlignmentLine = 0 + if (anyAlignBy) { + for (i in startIndex until endIndex) { + val placeable = placeables[i]!! + val parentData = rowColumnParentData[i] + val alignmentLinePosition = parentData.crossAxisAlignment + ?.calculateAlignmentLinePosition(placeable) + if (alignmentLinePosition != null) { + beforeCrossAxisAlignmentLine = max( + beforeCrossAxisAlignmentLine, + alignmentLinePosition.let { + if (it != AlignmentLine.Unspecified) it else 0 + } + ) + afterCrossAxisAlignmentLine = max( + afterCrossAxisAlignmentLine, + placeable.crossAxisSize() - + ( + alignmentLinePosition.let { + if (it != AlignmentLine.Unspecified) { + it + } else { + placeable.crossAxisSize() + } + } + ) + ) + } + } + } + + // Compute the Row or Column size and position the children. + val mainAxisLayoutSize = max(fixedSpace + weightedSpace, constraints.mainAxisMin) + val crossAxisLayoutSize = if (constraints.crossAxisMax != Constraints.Infinity && + crossAxisSize == SizeMode.Expand + ) { + constraints.crossAxisMax + } else { + max( + crossAxisSpace, + max( + constraints.crossAxisMin, + beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine + ) + ) + } + val mainAxisPositions = IntArray(subSize) { 0 } + val childrenMainAxisSize = IntArray(subSize) { index -> + placeables[index + startIndex]!!.mainAxisSize() + } + + return RowColumnMeasureHelperResult( + mainAxisSize = mainAxisLayoutSize, + crossAxisSize = crossAxisLayoutSize, + startIndex = startIndex, + endIndex = endIndex, + beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine, + mainAxisPositions = mainAxisPositions( + mainAxisLayoutSize, + childrenMainAxisSize, + mainAxisPositions, + measureScope + ) + ) + } + + private fun mainAxisPositions( + mainAxisLayoutSize: Int, + childrenMainAxisSize: IntArray, + mainAxisPositions: IntArray, + measureScope: MeasureScope + ): IntArray { + arrangement( + mainAxisLayoutSize, + childrenMainAxisSize, + measureScope.layoutDirection, + measureScope, + mainAxisPositions + ) + return mainAxisPositions + } + + protected fun getCrossAxisPosition( + placeable: Placeable, + parentData: RowColumnParentData?, + crossAxisLayoutSize: Int, + layoutDirection: LayoutDirection, + beforeCrossAxisAlignmentLine: Int + ): Int { + val childCrossAlignment = parentData?.crossAxisAlignment ?: crossAxisAlignment + return childCrossAlignment.align( + size = crossAxisLayoutSize - placeable.crossAxisSize(), + layoutDirection = if (orientation == LayoutOrientation.Horizontal) { + LayoutDirection.Ltr + } else { + layoutDirection + }, + placeable = placeable, + beforeCrossAxisAlignmentLine = beforeCrossAxisAlignmentLine + ) + } + + fun placeHelper( + placeableScope: Placeable.PlacementScope, + measureResult: RowColumnMeasureHelperResult, + crossAxisOffset: Int, + layoutDirection: LayoutDirection, + ) { + with(placeableScope) { + for (i in measureResult.startIndex until measureResult.endIndex) { + val placeable = placeables[i] + placeable!! + val mainAxisPositions = measureResult.mainAxisPositions + val crossAxisPosition = getCrossAxisPosition( + placeable, + (measurables[i].parentData as? RowColumnParentData), + measureResult.crossAxisSize, + layoutDirection, + measureResult.beforeCrossAxisAlignmentLine + ) + crossAxisOffset + if (orientation == LayoutOrientation.Horizontal) { + placeable.place( + mainAxisPositions[i - measureResult.startIndex], + crossAxisPosition + ) + } else { + placeable.place( + crossAxisPosition, + mainAxisPositions[i - measureResult.startIndex] + ) + } + } + } + } +} diff --git a/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/FoldAwareColumnTest.kt b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/FoldAwareColumnTest.kt new file mode 100644 index 000000000..efd48fe25 --- /dev/null +++ b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/FoldAwareColumnTest.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2023 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.annotation.SuppressLint +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toComposeRect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowLayoutInfo +import androidx.window.layout.WindowMetricsCalculator +import androidx.window.testing.layout.FoldingFeature +import androidx.window.testing.layout.WindowLayoutInfoPublisherRule +import com.google.accompanist.adaptive.FoldAwareColumnScopeInstance.ignoreFold +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FoldAwareColumnTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @get:Rule + val publisherRule = WindowLayoutInfoPublisherRule() + + private val testTag = "FoldAwareColumnTestTag" + private var firstSpacerHeightDp = 0.dp + private var secondSpacerTopPx = 0f + private var secondSpacerBottomPx = 0f + + @After + fun cleanUp() { + firstSpacerHeightDp = 0.dp + secondSpacerTopPx = 0f + secondSpacerBottomPx = 0f + } + + @Test + fun second_spacer_placed_below_fold_with_hinge() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + val foldBoundsPx = simulateFoldingFeature() + + assertEquals(foldBoundsPx.bottom, secondSpacerTopPx) + } + + @Test + fun second_spacer_placed_below_fold_with_separating_fold() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + val foldBoundsPx = simulateFoldingFeature(foldSizePx = 0) + + assertEquals(foldBoundsPx.bottom, secondSpacerTopPx) + } + + @Test + fun second_spacer_placed_below_first_spacer_without_fold() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) + } + + @Test + fun second_spacer_placed_below_first_spacer_with_non_separating_fold() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + simulateFoldingFeature(foldSizePx = 0, foldState = FoldingFeature.State.FLAT) + + composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) + } + + @Test + fun second_spacer_placed_below_first_spacer_with_vertical_hinge() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + simulateFoldingFeature(foldOrientation = FoldingFeature.Orientation.VERTICAL) + + composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) + } + + @Test + fun second_spacer_placed_below_first_spacer_with_ignore_fold_modifier() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers(secondSpacerModifier = Modifier.ignoreFold()) + } + + simulateFoldingFeature() + + composeTestRule.onNodeWithTag(testTag).assertTopPositionInRootIsEqualTo(firstSpacerHeightDp) + } + + @Test + fun even_fold_padding_modifier_applies_around_hinge() { + val foldPaddingDp = 20.dp + lateinit var density: Density + + composeTestRule.setContent { + density = LocalDensity.current + + FoldAwareColumnWithSpacers( + foldPadding = PaddingValues(vertical = foldPaddingDp) + ) + } + + val foldBoundsPx = simulateFoldingFeature() + + with(density) { + assertEquals(foldBoundsPx.bottom + foldPaddingDp.roundToPx(), secondSpacerTopPx) + } + } + + @Test + fun uneven_fold_padding_modifier_applies_around_hinge() { + val foldPaddingBottom = 40.dp + lateinit var density: Density + + composeTestRule.setContent { + density = LocalDensity.current + + FoldAwareColumnWithSpacers( + foldPadding = PaddingValues(top = 15.dp, bottom = foldPaddingBottom) + ) + } + + val foldBoundsPx = simulateFoldingFeature() + + with(density) { + assertEquals(foldBoundsPx.bottom + foldPaddingBottom.roundToPx(), secondSpacerTopPx) + } + } + + @Test + fun layout_bounds_align_with_child_bounds_without_separating_fold() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + val layoutBottomPx = composeTestRule.onRoot() + .fetchSemanticsNode().layoutInfo.coordinates.trueBoundsInWindow().bottom + + assertEquals(layoutBottomPx, secondSpacerBottomPx) + } + + @Test + fun layout_bounds_contain_child_bounds_when_placed_above_hinge() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers( + firstSpacerHeightPct = 0.1f, + secondSpacerHeightPct = 0.1f + ) + } + + simulateFoldingFeature() + + val layoutBottomPx = composeTestRule.onRoot() + .fetchSemanticsNode().layoutInfo.coordinates.trueBoundsInWindow().bottom + + assert(secondSpacerBottomPx <= layoutBottomPx) + } + + @Test + fun layout_bounds_contain_child_bounds_when_placed_below_hinge() { + composeTestRule.setContent { + FoldAwareColumnWithSpacers() + } + + simulateFoldingFeature() + + val layoutBottomPx = composeTestRule.onRoot() + .fetchSemanticsNode().layoutInfo.coordinates.trueBoundsInWindow().bottom + + assert(secondSpacerBottomPx <= layoutBottomPx) + } + + /** + * Test layout for FoldAwareColumn that includes two spacers with the provided heights + */ + @Composable + @SuppressLint("ModifierParameter") + private fun FoldAwareColumnWithSpacers( + foldPadding: PaddingValues = PaddingValues(), + firstSpacerHeightPct: Float = 0.25f, + secondSpacerHeightPct: Float = 0.25f, + secondSpacerModifier: Modifier = Modifier, + ) { + var secondSpacerHeightDp: Dp + val metrics = remember(LocalConfiguration.current) { + WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(composeTestRule.activity) + } + + with(LocalDensity.current) { + val windowHeight = metrics.bounds.height().toDp().value + firstSpacerHeightDp = (firstSpacerHeightPct * windowHeight).dp + secondSpacerHeightDp = (secondSpacerHeightPct * windowHeight).dp + } + + FoldAwareColumn( + displayFeatures = calculateDisplayFeatures(activity = composeTestRule.activity), + foldPadding = foldPadding, + ) { + Spacer( + modifier = Modifier.height(firstSpacerHeightDp) + ) + Spacer( + modifier = secondSpacerModifier + .height(secondSpacerHeightDp) + .testTag(testTag) + .onGloballyPositioned { + secondSpacerTopPx = it.positionInWindow().y + secondSpacerBottomPx = secondSpacerTopPx + it.size.height + } + ) + } + } + + /** + * Simulates a Jetpack Window Manager folding feature with the provided properties and returns + * the bounding box of the fold + */ + private fun simulateFoldingFeature( + foldSizePx: Int = 25, + foldState: FoldingFeature.State = FoldingFeature.State.HALF_OPENED, + foldOrientation: FoldingFeature.Orientation = FoldingFeature.Orientation.HORIZONTAL + ): Rect { + val fakeFoldingFeature = FoldingFeature( + activity = composeTestRule.activity, + size = foldSizePx, + state = foldState, + orientation = foldOrientation, + ) + + publisherRule.overrideWindowLayoutInfo(WindowLayoutInfo(listOf(fakeFoldingFeature))) + + composeTestRule.waitForIdle() + + return fakeFoldingFeature.bounds.toComposeRect() + } +} diff --git a/docs/adaptive.md b/docs/adaptive.md index 9ce9b0d0e..dbaede9d5 100644 --- a/docs/adaptive.md +++ b/docs/adaptive.md @@ -30,6 +30,20 @@ the [`TwoPane`](../api/adaptive/com.google.accompanist.adaptive/-two-pane.html) When there is no fold, the default supplied strategy will be used instead. +## FoldAwareColumn + +[`FoldAwareColumn`](../api/adaptive/com.google.accompanist.adaptive/-fold-aware-column.html) is a simplified version of [Column](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary#Column(androidx.compose.ui.Modifier,androidx.compose.foundation.layout.Arrangement.Vertical,androidx.compose.ui.Alignment.Horizontal,kotlin.Function1)) that places children in a fold-aware manner. + +[`FoldAwareColumn`](../api/adaptive/com.google.accompanist.adaptive/-fold-aware-column.html) requires a list of display features (to be retrieved with [`calculateDisplayFeatures`](#calculatedisplayfeatures)) to determine which folds to handle automatically. + +The built-in `foldPadding` parameter is zero, and the values of the vertical padding are used in the layout determine how much space should be left around a fold when placing children. + +When there is a horizontal fold that is obscuring or separating, the layout will begin placing children from the top of the available space. If a child is projected to overlap the fold, then its y-coordinate is increased so it will be placed fully below the fold, as will any other remaining children. + +When there is no fold, the children will be placed consecutively with no y-coordinate adjustments. + +Optionally, children can be modified with the `ignoreFold()` attribute, which means that they will be placed as if no fold is present even if they overlap a fold. + ## Download [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-adaptive)](https://search.maven.org/search?q=g:com.google.accompanist) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 73bd53eee..255f2eada 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -464,6 +464,36 @@ + + + + + + + + + + + + + + + + + + + + + + change.consume() + offset = Offset(offset.x + dragAmount.x, offset.y + dragAmount.y) + } + } + .width(400.dp) + .border(5.dp, MaterialTheme.colors.secondary), + displayFeatures = calculateDisplayFeatures(activity), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .border(2.dp, MaterialTheme.colors.primary) + .padding(20.dp) + .align(Alignment.Start), + imageVector = Icons.Default.FavoriteBorder, + contentDescription = null + ) + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .border(2.dp, MaterialTheme.colors.primary), + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + ) + Image( + modifier = Modifier + .ignoreFold() + .align(Alignment.End) + .border(2.dp, MaterialTheme.colors.primary), + painter = painterResource(id = R.drawable.placeholder), + contentDescription = null + ) + } +} diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt new file mode 100644 index 000000000..4b250952c --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavDrawerFoldAwareColumnSample.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2023 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.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.runtime.Composable +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.unit.dp +import com.google.accompanist.adaptive.FoldAwareColumn +import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.google.accompanist.sample.AccompanistSampleTheme + +class NavDrawerFoldAwareColumnSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AccompanistSampleTheme { + NavDrawerExample(this) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NavDrawerExample(activity: Activity) { + val icons = listOf( + Icons.Default.List, + Icons.Default.Done, + Icons.Default.Face, + Icons.Default.Lock, + Icons.Default.Search, + Icons.Default.ThumbUp, + Icons.Default.Warning, + Icons.Default.Star + ) + + var selectedIcon by remember { mutableStateOf(icons[0]) } + + ModalNavigationDrawer( + drawerContent = { + ModalDrawerSheet { + FoldAwareColumn( + displayFeatures = calculateDisplayFeatures(activity), + foldPadding = PaddingValues(vertical = 10.dp) + ) { + icons.forEach { + NavigationDrawerItem( + modifier = Modifier + .padding(5.dp) + .border(2.dp, MaterialTheme.colors.primary, CircleShape), + icon = { Icon(imageVector = it, contentDescription = it.name) }, + label = { Text(it.name.substringAfter('.')) }, + selected = it == selectedIcon, + onClick = { selectedIcon = it } + ) + } + } + } + }, + content = { Surface(modifier = Modifier.fillMaxSize()) {} } + ) +} diff --git a/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt new file mode 100644 index 000000000..e25693f5a --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/adaptive/NavRailFoldAwareColumnSample.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2023 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.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.NavigationRail +import androidx.compose.material.NavigationRailItem +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.Composable +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.unit.dp +import com.google.accompanist.adaptive.FoldAwareColumn +import com.google.accompanist.adaptive.calculateDisplayFeatures +import com.google.accompanist.sample.AccompanistSampleTheme + +class NavRailFoldAwareColumnSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AccompanistSampleTheme { + Row { + NavRail(this@NavRailFoldAwareColumnSample) + Surface(modifier = Modifier.fillMaxSize()) {} + } + } + } + } +} + +@Composable +fun NavRail(activity: Activity) { + val icons = listOf( + Icons.Default.List, + Icons.Default.Done, + Icons.Default.Face, + Icons.Default.Lock, + Icons.Default.Search, + Icons.Default.ThumbUp, + Icons.Default.Warning, + Icons.Default.Star + ) + + var selectedIcon by remember { mutableStateOf(icons[0]) } + + NavigationRail { + FoldAwareColumn(displayFeatures = calculateDisplayFeatures(activity)) { + icons.forEach { + NavigationRailItem( + modifier = Modifier + .padding(5.dp) + .border(2.dp, MaterialTheme.colors.primary), + selected = it == selectedIcon, + onClick = { selectedIcon = it }, + icon = { Icon(imageVector = it, contentDescription = it.name) }, + label = { Text(it.name.substringAfter('.')) } + ) + } + } + } +} diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 80bb8ece8..5a9126c54 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -69,7 +69,10 @@ Adaptive: TwoPane Basic Adaptive: TwoPane Horizontal Adaptive: TwoPane Vertical - + Adaptive: Fold Aware Column with Nav Rail + Adaptive: Fold Aware Column with Nav Drawer + Adaptive: Draggable Fold Aware Column + Test Harness This is content\n%s