Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 21e72d19 authored by Shamali Patwa's avatar Shamali Patwa
Browse files

Add a bottom sheet with title and description on top.

Acts as a common structure that can be used across all three types of widget pickers.
- full catalog -> full expanded
- single app catalog / pin widget -> wraps the content until full size

In case of extra tall heights, height of sheet is capped at 2/3 size.

Bug: 408283627
Test: On device; an end to end screenshot test will provide automation coverage
Flag: EXEMPT new lib
Change-Id: If605104bfcc096de3f04558a818e4fec1d1666ed
parent cf7b2e2f
Loading
Loading
Loading
Loading
+17 −0
Original line number Diff line number Diff line
@@ -91,6 +91,7 @@ android_library {
    srcs: [
        "src/com/android/widgetpicker/ui/components/ScrollableFloatingToolbar.kt",
        "src/com/android/widgetpicker/ui/components/LeadingIconToolbarTab.kt",
        "src/com/android/widgetpicker/ui/components/TitledBottomSheet.kt",
    ],
    static_libs: [
        "androidx.compose.foundation_foundation",
@@ -101,5 +102,21 @@ android_library {
        "androidx.compose.material3_material3",
        "androidx.compose.material3_material3-window-size-class",
        "androidx.compose.material_material-icons-extended",
        "androidx.activity_activity-compose",
        "widget_picker_window_size_class",
    ],
}

android_library {
    name: "widget_picker_window_size_class",
    sdk_version: "current",
    min_sdk_version: min_widget_picker_sdk_version,
    srcs: [
        "src/com/android/widgetpicker/ui/windowsizeclass/WindowSizeClass.kt",
    ],
    static_libs: [
        "androidx.compose.runtime_runtime",
        "androidx.compose.material3_material3-window-size-class",
        "androidx.window_window",
    ],
}
+5 −0
Original line number Diff line number Diff line
@@ -75,6 +75,11 @@ dependencies {
    implementation(libs.compose.runtime)
    implementation(libs.compose.foundation.layout)
    implementation(libs.compose.material3)
    implementation(libs.androidx.activity.compose)

    // Other UI dependencies
    implementation(libs.androidx.material3.window.size.cls)
    implementation(libs.androidx.window)

    // Compose android studio preview support
    implementation(libs.compose.material.icons.extended)
+225 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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
 *
 *      http://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.android.widgetpicker.ui.components

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import com.android.widgetpicker.ui.components.DragHandleDimens.dragHandleHeight
import com.android.widgetpicker.ui.components.DragHandleDimens.dragHandleWidth
import com.android.widgetpicker.ui.components.TitledBottomSheetDimens.headerBottomMargin
import com.android.widgetpicker.ui.components.TitledBottomSheetDimens.sheetInnerHorizontalPadding
import com.android.widgetpicker.ui.components.TitledBottomSheetDimens.sheetInnerTopPadding
import com.android.widgetpicker.ui.windowsizeclass.WindowInfo
import com.android.widgetpicker.ui.windowsizeclass.calculateWindowInfo
import com.android.widgetpicker.ui.windowsizeclass.isExtraTall

/**
 * A bottom sheet with title and description on the top. Intended to serve as a common container
 * structure for different types of widget pickers.
 *
 * @param modifier modifier to be applies to the bottom sheet container.
 * @param title A top level title for the bottom sheet.
 * @param description an optional short (1-2 line) description that can be shown below the title.
 * @param heightStyle indicates how much vertical space should the bottom sheet take; see
 * [ModalBottomSheetHeightStyle].
 * @param showDragHandle whether to show drag handle; e.g. if the content doesn't need scrolling set
 * this to false.
 * @param onDismissRequest callback to be invoked when the bottom sheet is closed
 * @param content the content to be displayed below the [title] and [description]
 */
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun TitledBottomSheet(
    modifier: Modifier = Modifier,
    title: String,
    description: String?,
    heightStyle: ModalBottomSheetHeightStyle,
    showDragHandle: Boolean = true,
    onDismissRequest: () -> Unit,
    content: @Composable () -> Unit
) {
    val modalBottomSheetState = rememberModalBottomSheetState(
        skipPartiallyExpanded = true,
        confirmValueChange = { value ->
            value != SheetValue.PartiallyExpanded
        }
    )
    val windowInfo = LocalConfiguration.current.calculateWindowInfo()

    val dragHandle: (@Composable () -> Unit)? = remember(showDragHandle) {
        if (showDragHandle) {
            {
                DragHandle(
                    modifier = Modifier.padding(
                        top = sheetInnerTopPadding,
                        bottom = headerBottomMargin
                    )
                )
            }
        } else null
    }

    ModalBottomSheet(
        sheetState = modalBottomSheetState,
        sheetMaxWidth = Dp.Unspecified,
        containerColor = MaterialTheme.colorScheme.surfaceContainer,
        onDismissRequest = onDismissRequest,
        dragHandle = dragHandle,
        modifier = modifier
            .windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.displayCutout))
    ) {
        Column(
            modifier = Modifier
                .sheetContentHeight(heightStyle, windowInfo)
                .padding(horizontal = sheetInnerHorizontalPadding)
                .padding(top = sheetInnerTopPadding.takeIf { !showDragHandle } ?: 0.dp)
        ) {
            Header(
                title = title,
                description = description
            )
            content()
        }
    }
}

@Composable
private fun DragHandle(modifier: Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.outline,
        shape = MaterialTheme.shapes.medium
    ) {
        Box(Modifier.size(width = dragHandleWidth, height = dragHandleHeight))
    }
}

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun Header(
    title: String,
    description: String?,
) {
    Column(
        modifier = Modifier
            .padding(bottom = headerBottomMargin)
            .fillMaxWidth()
    ) {
        Text(
            modifier = Modifier
                .wrapContentHeight()
                .fillMaxWidth(),
            maxLines = 1,
            text = title,
            textAlign = TextAlign.Center,
            style = MaterialTheme.typography.headlineSmallEmphasized,
            color = MaterialTheme.colorScheme.onSurface
        )
        description?.let {
            Text(
                modifier = Modifier
                    .wrapContentHeight()
                    .fillMaxWidth(),
                maxLines = 2,
                text = it,
                textAlign = TextAlign.Center,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

private object TitledBottomSheetDimens {
    val sheetInnerTopPadding = 16.dp
    val sheetInnerHorizontalPadding = 10.dp
    val headerBottomMargin = 24.dp
}

private object DragHandleDimens {
    val dragHandleHeight = 4.dp
    val dragHandleWidth = 32.dp
}

/**
 * Describes how should the default height of the bottom sheet look like (excluding the insets such
 * as status bar).
 */
enum class ModalBottomSheetHeightStyle {
    /**
     * Fills the available height; capped to a max for extra tall cases.
     * Useful for cases where irrespective of content, we want it to be expanded fully.
     */
    FILL_HEIGHT,

    /**
     * Wraps the content's height; capped to a max for extra tall cases.
     * Set up vertical scrolling if the content can be longer than the available height.
     * Useful for cases like single app widget picker or pin widget picker that don't need to expand
     * fully.
     */
    WRAP_CONTENT;
}

@Composable
private fun Modifier.sheetContentHeight(
    style: ModalBottomSheetHeightStyle,
    windowInfo: WindowInfo
): Modifier {
    val heightModifier = when (style) {
        ModalBottomSheetHeightStyle.FILL_HEIGHT -> this
            .fillMaxHeight()

        ModalBottomSheetHeightStyle.WRAP_CONTENT -> this
            .wrapContentHeight()
    }

    return if (windowInfo.isExtraTall()) {
        // Cap the height to max 2/3 of total window height; so the bottom sheet doesn't feel too
        // huge.
        heightModifier.heightIn(max = 2 * windowInfo.size.height / 3)
    } else {
        heightModifier
    }
}
+60 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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
 *
 *      http://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.android.widgetpicker.ui.windowsizeclass

import android.content.res.Configuration
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toComposeRect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.window.layout.WindowMetricsCalculator

/**
 * Provides window size for the current configuration.
 *
 * Unlike "calculateWindowSizeClass" provided by windowSizeClass material library, this works
 * independent of whether you are running with activity (real app) or not (android studio preview).
 * And the "currentWindowAdaptiveInfo" provided by material adaptive library seems deprecated.
 */
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
// Suppress: Configuration is used as receiver to indicate recomposition on config changes.
@Suppress("UnusedReceiverParameter")
fun Configuration.calculateWindowInfo(): WindowInfo {
    val density = LocalDensity.current
    val context = LocalContext.current
    val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
    val size = with(density) { metrics.bounds.toComposeRect().size.toDpSize() }
    return WindowInfo(WindowSizeClass.calculateFromSize(size), size)
}

/**
 * Information about the window's size.
 */
data class WindowInfo(
    val windowSizeClass: WindowSizeClass,
    val size: DpSize,
)

/**
 * Returns true if the window is extra tall e.g. portrait on a tablet.
 */
fun WindowInfo.isExtraTall() = size.height > 1200.dp