From acea3cedce37eedcd09d0041cb29c72cb77be886 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Sun, 8 Oct 2023 10:43:09 +0200 Subject: [PATCH] Depend on upstream Cascade --- app/build.gradle.kts | 2 + .../ui/components/common/DropdownMenu.kt | 4 +- .../ui/components/community/Community.kt | 2 +- .../com/jerboa/ui/components/home/Home.kt | 2 +- .../java/com/jerboa/util/cascade/Cascade.kt | 405 ------------------ .../com/jerboa/util/cascade/CascadeState.kt | 55 --- .../util/cascade/CustomCascadeDropdown.kt | 31 +- .../util/cascade/internal/AnimateEntryExit.kt | 184 -------- .../cascade/internal/CascadeTransitionSpec.kt | 33 -- .../internal/ClickableWithoutRipple.kt | 19 - .../cascade/internal/CoercePositiveValues.kt | 42 -- .../internal/DropdownMenuPositionProvider.kt | 114 ----- .../util/cascade/internal/PopupProperties.kt | 19 - .../cascade/internal/PositionPopupContent.kt | 81 ---- .../cascade/internal/ScreenRelativeBounds.kt | 60 --- 15 files changed, 26 insertions(+), 1027 deletions(-) delete mode 100644 app/src/main/java/com/jerboa/util/cascade/Cascade.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/CascadeState.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/CascadeTransitionSpec.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/ClickableWithoutRipple.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/PopupProperties.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt delete mode 100644 app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ee37d53f..8432519d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,6 +132,8 @@ dependencies { implementation("io.coil-kt:coil-svg:2.4.0") // Allows for proper subsampling of large images implementation("me.saket.telephoto:zoomable-image-coil:0.7.0-20230922.054002-2") + // Animated dropdowns + implementation("me.saket.cascade:cascade-compose:2.3.0") // crash handling implementation("com.github.FunkyMuse:Crashy:1.2.0") diff --git a/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt b/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt index 51e484c13..bfb49c550 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/DropdownMenu.kt @@ -31,8 +31,8 @@ import com.jerboa.datatypes.types.SortType import com.jerboa.ui.theme.LARGE_PADDING import com.jerboa.ui.theme.POPUP_MENU_WIDTH_RATIO import com.jerboa.ui.theme.Shapes -import com.jerboa.util.cascade.CascadeColumnScope -import com.jerboa.util.cascade.CascadeDropdownMenu +import me.saket.cascade.CascadeColumnScope +import me.saket.cascade.CascadeDropdownMenu val isTopSort = { sort: SortType -> sort.name.startsWith("Top") } diff --git a/app/src/main/java/com/jerboa/ui/components/community/Community.kt b/app/src/main/java/com/jerboa/ui/components/community/Community.kt index abcf7ed2e..b13e92bf5 100644 --- a/app/src/main/java/com/jerboa/ui/components/community/Community.kt +++ b/app/src/main/java/com/jerboa/ui/components/community/Community.kt @@ -24,7 +24,7 @@ import com.jerboa.ui.components.common.LargerCircularIcon import com.jerboa.ui.components.common.PictrsBannerImage import com.jerboa.ui.components.common.SortOptionsDropdown import com.jerboa.ui.theme.* -import com.jerboa.util.cascade.CascadeDropdownMenu +import me.saket.cascade.CascadeDropdownMenu @Composable fun CommunityTopSection( diff --git a/app/src/main/java/com/jerboa/ui/components/home/Home.kt b/app/src/main/java/com/jerboa/ui/components/home/Home.kt index bec376878..bcf67b43b 100644 --- a/app/src/main/java/com/jerboa/ui/components/home/Home.kt +++ b/app/src/main/java/com/jerboa/ui/components/home/Home.kt @@ -49,8 +49,8 @@ import com.jerboa.ui.components.common.MenuItem import com.jerboa.ui.components.common.MyMarkdownText import com.jerboa.ui.components.common.SortOptionsDropdown import com.jerboa.ui.theme.LARGE_PADDING -import com.jerboa.util.cascade.CascadeDropdownMenu import kotlinx.collections.immutable.ImmutableList +import me.saket.cascade.CascadeDropdownMenu @Composable fun HomeHeaderTitle( diff --git a/app/src/main/java/com/jerboa/util/cascade/Cascade.kt b/app/src/main/java/com/jerboa/util/cascade/Cascade.kt deleted file mode 100644 index bcc2f3c43..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/Cascade.kt +++ /dev/null @@ -1,405 +0,0 @@ -@file:OptIn(ExperimentalAnimationApi::class) - -package com.jerboa.util.cascade - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.LayoutScopeMarker -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowLeft -import androidx.compose.material.icons.rounded.ArrowRight -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuDefaults -import androidx.compose.material3.MenuItemColors -import androidx.compose.material3.Surface -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.LayoutDirection.Ltr -import androidx.compose.ui.unit.LayoutDirection.Rtl -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import com.jerboa.util.cascade.internal.AnimateEntryExit -import com.jerboa.util.cascade.internal.CoercePositiveValues -import com.jerboa.util.cascade.internal.DropdownMenuPositionProvider -import com.jerboa.util.cascade.internal.PositionPopupContent -import com.jerboa.util.cascade.internal.ScreenRelativeBounds -import com.jerboa.util.cascade.internal.calculateTransformOrigin -import com.jerboa.util.cascade.internal.cascadeTransitionSpec -import com.jerboa.util.cascade.internal.clickableWithoutRipple -import com.jerboa.util.cascade.internal.copy -import com.jerboa.util.cascade.internal.then -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onEach - -// TODO: remove and depend on upstream once fixes have been merged - -/** - * Material Design dropdown menu with support for nested menus. - * See [DropdownMenu] for documentation about its parameters. - * - * Example usage: - * - * ``` - * var expanded by rememberSaveable { mutableStateOf(false) } - * - * CascadeDropdownMenu( - * expanded = expanded, - * onDismissRequest = { expanded = false } - * ) { - * DropdownMenuItem( - * text = { Text("Horizon") }, - * children = { - * DropdownMenuItem( - * text = { Text("Zero Dawn") }, - * onClick = { … } - * ) - * DropdownMenuItem( - * text = { Text("Forbidden West") }, - * onClick = { … } - * ) - * } - * ) - * } - * ``` - * - * @param fixedWidth A width that will be shared by all nested menus. This can be removed - * in the future once cascade is able to animate width changes across nested menus. - * - * @param shadowElevation A value between 0dp and 8dp. Cascade trims values above 8dp to match [DropdownMenu]'s behavior. - * [More context can be found here](https://android-review.googlesource.com/c/platform/frameworks/support/+/2117953). - */ -@Composable -fun CascadeDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset.Zero, - fixedWidth: Dp = 196.dp, - shadowElevation: Dp = 3.dp, - tonalElevation: Dp = 3.dp, - properties: PopupProperties = PopupProperties(focusable = true), - state: CascadeState = rememberCascadeState(), - content: @Composable CascadeColumnScope.() -> Unit, -) { - val expandedStates = remember { MutableTransitionState(false) } - expandedStates.targetState = expanded - - if (expandedStates.currentState || expandedStates.targetState) { - val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } - val popupPositionProvider = CoercePositiveValues( - DropdownMenuPositionProvider( - offset, - LocalDensity.current, - ) { parentBounds, menuBounds -> - transformOriginState.value = calculateTransformOrigin( - parentBounds = parentBounds, - menuBounds = CoercePositiveValues.correctMenuBounds(menuBounds), - ) - }, - ) - - val anchorHostView = LocalView.current - var anchorBounds: ScreenRelativeBounds? by remember { mutableStateOf(null) } - Box( - Modifier.onGloballyPositioned { coordinates -> - // FYI: - // coordinates -> this box. - // coordinates.parent -> "anchor" composable that contains CascadeDropdownMenu(). - anchorBounds = ScreenRelativeBounds(coordinates.parentLayoutCoordinates!!, owner = anchorHostView) - }, - ) - - // A full sized popup is shown so that content can render fake shadows - // that do not suffer from https://issuetracker.google.com/issues/236109671. - Popup( - onDismissRequest = onDismissRequest, - properties = properties.copy(usePlatformDefaultWidth = false), - ) { - PositionPopupContent( - modifier = Modifier - .fillMaxSize() - .then(properties.dismissOnClickOutside) { - clickableWithoutRipple(onClick = onDismissRequest) - }, - positionProvider = popupPositionProvider, - anchorBounds = anchorBounds, - ) { - PopupContent( - modifier = Modifier - // Prevent clicks from leaking behind. Otherwise, they'll get picked up as outside - // clicks to dismiss the popup. This must be set _before_ the downstream modifiers to - // avoid overriding any clickable modifiers registered by the developer. - .clickableWithoutRipple {} - .then(modifier), - state = state, - fixedWidth = fixedWidth, - expandedStates = expandedStates, - transformOriginState = transformOriginState, - shadowElevation = shadowElevation, - tonalElevation = tonalElevation, - content = content, - ) - } - } - } -} - -@Composable -internal fun PopupContent( - modifier: Modifier = Modifier, - state: CascadeState, - fixedWidth: Dp, - tonalElevation: Dp, - shadowElevation: Dp, - expandedStates: MutableTransitionState, - transformOriginState: MutableState, - content: - @Composable() - (CascadeColumnScope.() -> Unit), -) { - AnimateEntryExit( - expandedStates = expandedStates, - transformOriginState = transformOriginState, - // 8dp is the maximum recommended elevation. - // More context here: https://android-review.googlesource.com/c/platform/frameworks/support/+/2117953 - shadowElevation = shadowElevation.coerceAtMost(8.dp), - ) { - CascadeDropdownMenuContent( - modifier = Modifier - .requiredWidth(fixedWidth) - .then(modifier), - state = state, - tonalElevation = tonalElevation, - shadowElevation = shadowElevation, - content = content, - ) - } -} - -@Composable -private fun CascadeDropdownMenuContent( - state: CascadeState, - modifier: Modifier = Modifier, - tonalElevation: Dp, - shadowElevation: Dp, - content: @Composable CascadeColumnScope.() -> Unit, -) { - DisposableEffect(Unit) { - onDispose { - state.resetBackStack() - } - } - - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.surface, - tonalElevation = tonalElevation, - shadowElevation = shadowElevation, - ) { - val isTransitionRunning = remember { MutableStateFlow(false) } - val backStackSnapshot by remember { - snapshotFlow { state.backStackSnapshot() } - .onEach { - // Block until any ongoing transition has finished. This is a very crude - // way of queueing navigations. AnimatedContent() does not like it when - // its content is changed before it is able to finish a transition. - isTransitionRunning.first { running -> !running } - } - }.collectAsState(initial = state.backStackSnapshot()) - - val layoutDirection = LocalLayoutDirection.current - AnimatedContent( - modifier = modifier, - targetState = backStackSnapshot, - transitionSpec = { cascadeTransitionSpec(layoutDirection) }, - label = "cascadeAnimation", - ) { snapshot -> - Column( - Modifier - // Provide a solid background color to prevent the - // content of sub-menus from leaking into each other. - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(tonalElevation)) - .verticalScroll(rememberScrollState()), - ) { - val currentContent = snapshot.topMostEntry?.childrenContent ?: content - snapshot.topMostEntry?.header?.invoke() - - val contentScope = remember { CascadeColumnScope(state) } - contentScope.currentContent() - } - - LaunchedEffect(transition.isRunning) { - isTransitionRunning.tryEmit(transition.isRunning) - } - } - } -} - -@Immutable -@LayoutScopeMarker -interface CascadeColumnScope : ColumnScope { - val cascadeState: CascadeState - - /** - * Material Design dropdown menu item that navigates to a sub-menu on click. - * See [androidx.compose.material3.DropdownMenuItem] for documentation about its parameters. - * - * For sub-menus, cascade will automatically navigate to their parent menu when their - * header is clicked. For manual navigation, [CascadeState.navigateBack] can be used. - * - * ``` - * val state = rememberCascadeState() - * - * CascadeDropdownMenu(state = state, ...) { - * DropdownMenuItem( - * text = { Text("Are you sure?"), - * children = { - * DropdownMenuItem( - * text = { Text("Not really") }, - * onClick = { state.navigateBack() } - * ) - * } - * ) - * } - * ``` - */ - @Composable - fun DropdownMenuItem( - text: @Composable () -> Unit, - children: @Composable CascadeColumnScope.() -> Unit, - modifier: Modifier = Modifier, - childrenHeader: @Composable () -> Unit = { DropdownMenuHeader(text = text) }, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - enabled: Boolean = true, - colors: MenuItemColors = MenuDefaults.itemColors(), - contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - ) { - DropdownMenuItem( - text = text, - onClick = { - cascadeState.navigateTo( - CascadeBackStackEntry( - header = childrenHeader, - childrenContent = children, - ), - ) - }, - modifier = modifier, - leadingIcon = leadingIcon, - trailingIcon = { - Row(verticalAlignment = CenterVertically) { - trailingIcon?.invoke() - - val requiredGapWithEdge = 4.dp - val iconOffset = contentPadding.calculateEndPadding(LocalLayoutDirection.current) - requiredGapWithEdge - Icon( - modifier = Modifier.offset(x = iconOffset), - imageVector = when (LocalLayoutDirection.current) { - Ltr -> Icons.Rounded.ArrowRight - Rtl -> Icons.Rounded.ArrowLeft - }, - contentDescription = null, - ) - } - }, - enabled = enabled, - colors = colors, - contentPadding = contentPadding, - interactionSource = interactionSource, - ) - } - - /** - * Displays `text` with a back icon. Navigates to its parent menu when clicked. - */ - @Composable - fun DropdownMenuHeader( - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(vertical = 4.dp), - text: @Composable () -> Unit, - ) { - Row( - modifier = modifier - .clickable { cascadeState.navigateBack() } - .fillMaxWidth() - .padding(contentPadding), - verticalAlignment = CenterVertically, - ) { - val headerColor = LocalContentColor.current.copy(alpha = 0.6f) - val headerStyle = MaterialTheme.typography.labelLarge.run { // labelLarge is also used by DropdownMenuItem(). - copy( - fontSize = fontSize * 0.9f, - letterSpacing = letterSpacing * 0.9f, - ) - } - CompositionLocalProvider( - LocalContentColor provides headerColor, - LocalTextStyle provides headerStyle, - ) { - Icon( - modifier = Modifier.requiredSize(32.dp), - imageVector = when (LocalLayoutDirection.current) { - Ltr -> Icons.Rounded.ArrowLeft - Rtl -> Icons.Rounded.ArrowRight - }, - contentDescription = null, - ) - Box(Modifier.weight(1f)) { - text() - } - } - } - } -} - -private fun ColumnScope.CascadeColumnScope(state: CascadeState): CascadeColumnScope = - object : CascadeColumnScope, ColumnScope by this { - override val cascadeState get() = state - } diff --git a/app/src/main/java/com/jerboa/util/cascade/CascadeState.kt b/app/src/main/java/com/jerboa/util/cascade/CascadeState.kt deleted file mode 100644 index 1ff601293..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/CascadeState.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.jerboa.util.cascade - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember - -@Composable -fun rememberCascadeState(): CascadeState { - return remember { CascadeState() } -} - -/** - * The state of a [CascadeDropdownMenu]. - */ -@Stable -class CascadeState internal constructor() { - private val backStack = mutableStateListOf() - - fun navigateBack() { - backStack.removeLast() - } - - fun resetBackStack() { - backStack.clear() - } - - fun canNavigateBack(): Boolean { - return backStack.isNotEmpty() - } - - internal fun navigateTo(entry: CascadeBackStackEntry) { - backStack.add(entry) - } - - internal fun backStackSnapshot(): BackStackSnapshot { - return BackStackSnapshot( - topMostEntry = backStack.lastOrNull(), - backStackSize = backStack.size, - ) - } -} - -@Immutable -internal class CascadeBackStackEntry( - val header: @Composable () -> Unit, - val childrenContent: @Composable CascadeColumnScope.() -> Unit, -) - -@Immutable -internal data class BackStackSnapshot( - val topMostEntry: CascadeBackStackEntry?, - val backStackSize: Int, -) diff --git a/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt b/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt index 8c7aa0161..db57e43af 100644 --- a/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt +++ b/app/src/main/java/com/jerboa/util/cascade/CustomCascadeDropdown.kt @@ -1,3 +1,7 @@ +// This hack is needed to depend on internal components of Cascade +// Author did not want to upstream this animated centered popup +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package com.jerboa.util.cascade import androidx.compose.animation.core.MutableTransitionState @@ -5,12 +9,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.Dp @@ -19,9 +22,14 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.jerboa.ui.theme.LARGE_PADDING import com.jerboa.ui.theme.POPUP_MENU_WIDTH_RATIO -import com.jerboa.util.cascade.internal.clickableWithoutRipple -import com.jerboa.util.cascade.internal.copy -import com.jerboa.util.cascade.internal.then +import me.saket.cascade.CascadeColumnScope +import me.saket.cascade.CascadeDefaults +import me.saket.cascade.CascadeState +import me.saket.cascade.rememberCascadeState +import me.saket.cascade.PopupContent as CascadePopupContent +import me.saket.cascade.internal.clickableWithoutRipple as clickableWithoutRippleCascade +import me.saket.cascade.internal.copy as copy +import me.saket.cascade.internal.then as thenCascade @Composable fun CascadeCenteredDropdownMenu( @@ -29,9 +37,10 @@ fun CascadeCenteredDropdownMenu( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, fixedWidth: Dp = LocalConfiguration.current.screenWidthDp.dp * POPUP_MENU_WIDTH_RATIO, - shadowElevation: Dp = 3.dp, + shadowElevation: Dp = CascadeDefaults.shadowElevation, properties: PopupProperties = PopupProperties(focusable = true), state: CascadeState = rememberCascadeState(), + shape: Shape = CascadeDefaults.shape, content: @Composable CascadeColumnScope.() -> Unit, ) { val expandedStates = remember { MutableTransitionState(false) } @@ -51,17 +60,17 @@ fun CascadeCenteredDropdownMenu( Box( Modifier .fillMaxSize() - .then(properties.dismissOnClickOutside) { - clickableWithoutRipple(onClick = onDismissRequest) + .thenCascade(properties.dismissOnClickOutside) { + clickableWithoutRippleCascade(onClick = onDismissRequest) }, Alignment.Center, ) { - PopupContent( + CascadePopupContent( modifier = Modifier // Prevent clicks from leaking behind. Otherwise, they'll get picked up as outside // clicks to dismiss the popup. This must be set _before_ the downstream modifiers to // avoid overriding any clickable modifiers registered by the developer. - .clickableWithoutRipple {} + .clickableWithoutRippleCascade {} .padding(vertical = LARGE_PADDING) .then(modifier), state = state, @@ -69,7 +78,7 @@ fun CascadeCenteredDropdownMenu( expandedStates = expandedStates, transformOriginState = transformOriginState, shadowElevation = shadowElevation, - tonalElevation = 3.dp, + shape = shape, content = content, ) } diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt b/app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt deleted file mode 100644 index 8a441d921..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/AnimateEntryExit.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ClipOp -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.addOutline -import androidx.compose.ui.graphics.asAndroidPath -import androidx.compose.ui.graphics.drawscope.clipPath -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection - -private const val InTransitionDuration = 300 -private const val OutTransitionDuration = 300 - -@Composable -internal fun AnimateEntryExit( - modifier: Modifier = Modifier, - expandedStates: MutableTransitionState, - transformOriginState: State, - shadowElevation: Dp, - content: @Composable () -> Unit, -) { - val isExpandedTransition = updateTransition(expandedStates, label = "CascadeDropDownMenu") - val scale by isExpandedTransition.animateFloat( - transitionSpec = { - tween(if (false isTransitioningTo true) InTransitionDuration else OutTransitionDuration) - }, - label = "scale", - targetValueByState = { if (it) 1f else 0f }, - ) - val alpha by isExpandedTransition.animateFloat( - transitionSpec = { - tween(if (false isTransitioningTo true) InTransitionDuration else OutTransitionDuration) - }, - label = "alpha", - targetValueByState = { if (it) 1f else 0f }, - ) - val reveal by isExpandedTransition.animateFloat( - transitionSpec = { - tween((if (false isTransitioningTo true) InTransitionDuration * 1.2 else OutTransitionDuration * 0.8).toInt()) - }, - label = "clip", - targetValueByState = { if (it) 1f else 0.25f }, - ) - - val shape = MaterialTheme.shapes.extraSmall - - val clippingShape = remember(reveal) { - object : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density, - ): Outline { - val outline = shape.createOutline( - size = size, - layoutDirection = layoutDirection, - density = density, - ) - return when (outline) { - is Outline.Generic, - is Outline.Rectangle, - -> { - Outline.Rectangle( - Rect(Offset.Zero, size = size.copy(height = size.height * reveal)), - ) - } - - is Outline.Rounded -> { - Outline.Rounded( - RoundRect( - rect = Rect(Offset.Zero, size = size.copy(height = size.height * reveal)), - topLeft = outline.roundRect.topLeftCornerRadius, - topRight = outline.roundRect.topRightCornerRadius, - bottomRight = outline.roundRect.bottomRightCornerRadius, - bottomLeft = outline.roundRect.bottomLeftCornerRadius, - ), - ) - } - } - } - } - } - - Box( - modifier.scale(scale, transformOrigin = transformOriginState.value), - ) { - // Drop shadows and content are drawn in separate sibling layouts because: - // - // - shadow().alpha() will not apply alpha to shadows. - // - // - alpha().shadow() will cause shadows to get clipped of content bounds - // because its usage of graphicsLayer(). - // - // - shadow() applied on the parent will cause shadows to get clipped outside - // of Popup's bounds, e.g., behind the status bar. - // - // FWIW material3.DropdownMenu() also suffers from these same problems. Its - // shadows get clipped during entry/exit transitions, but the 8dp shadows are - // small enough for the clipping to go unnoticed. - Box( - Modifier - .matchParentSize() - // Because the drop shadows are drawn separately from the popup's content, - // this layout's inner shadows must be clipped out to prevent it from - // showing up behind the translucent content. - .then(alpha < 1f) { clipDifference(clippingShape) } - .shadow( - elevation = shadowElevation, - shape = clippingShape, - clip = false, - ambientColor = Color.Black.copy(alpha = alpha), - spotColor = Color.Black.copy(alpha = alpha), - ), - ) - - Box( - Modifier - .alpha(alpha) - .clip(clippingShape), - ) { - content() - } - } -} - -// Like Modifier.clip() but uses ClipOp.Difference instead of ClipOp.Intersect. -private fun Modifier.clipDifference(shape: Shape): Modifier = composed { - val path = remember { Path() } - drawWithCache { - path.asAndroidPath().rewind() // rewind() is faster than reset(). - path.addOutline( - shape.createOutline( - size = size, - layoutDirection = layoutDirection, - density = this, - ), - ) - onDrawWithContent { - clipPath(path, ClipOp.Difference) { - this@onDrawWithContent.drawContent() - } - } - } -} - -@Stable -fun Modifier.scale(scale: Float, transformOrigin: TransformOrigin): Modifier { - return if (scale != 1f) { - graphicsLayer( - scaleX = scale, - scaleY = scale, - transformOrigin = transformOrigin, - ) - } else { - this - } -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/CascadeTransitionSpec.kt b/app/src/main/java/com/jerboa/util/cascade/internal/CascadeTransitionSpec.kt deleted file mode 100644 index 8db2554c1..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/CascadeTransitionSpec.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.LayoutDirection.Ltr -import com.jerboa.util.cascade.BackStackSnapshot - -internal fun AnimatedContentTransitionScope.cascadeTransitionSpec( - layoutDirection: LayoutDirection, -): ContentTransform { - val navigatingForward = targetState.backStackSize > initialState.backStackSize - - val inverseMultiplier = if (layoutDirection == Ltr) 1 else -1 - val initialOffset = { width: Int -> - inverseMultiplier * if (navigatingForward) width else -width / 4 - } - val targetOffset = { width: Int -> - inverseMultiplier * if (navigatingForward) -width / 4 else width - } - - val duration = 350 - return ContentTransform( - targetContentEnter = slideInHorizontally(tween(duration), initialOffset), - initialContentExit = slideOutHorizontally(tween(duration), targetOffset), - targetContentZIndex = targetState.backStackSize.toFloat(), - sizeTransform = SizeTransform(sizeAnimationSpec = { _, _ -> tween(duration) }), - ) -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/ClickableWithoutRipple.kt b/app/src/main/java/com/jerboa/util/cascade/internal/ClickableWithoutRipple.kt deleted file mode 100644 index c85036c12..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/ClickableWithoutRipple.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed - -internal fun Modifier.clickableWithoutRipple(onClick: () -> Unit) = composed { - clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick, - ) -} - -internal inline fun Modifier.then(predicate: Boolean, modifier: Modifier.() -> Modifier): Modifier { - return if (predicate) modifier() else this -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt b/app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt deleted file mode 100644 index 95b225679..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/CoercePositiveValues.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.window.PopupPositionProvider - -// TODO: this can be removed when https://issuetracker.google.com/issues/265547235 is fixed. -@Immutable -internal data class CoercePositiveValues( - val delegate: PopupPositionProvider, -) : PopupPositionProvider { - - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val position = delegate.calculatePosition( - anchorBounds = anchorBounds, - windowSize = windowSize, - layoutDirection = layoutDirection, - popupContentSize = popupContentSize, - ) - return position.copy( - x = maxOf(0, position.x), - y = maxOf(0, position.y), - ) - } - - companion object { - internal fun correctMenuBounds(menuBounds: IntRect): IntRect { - return menuBounds.translate( - translateX = minOf(0, menuBounds.left) * -1, - translateY = minOf(0, menuBounds.top) * -1, - ) - } - } -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt b/app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt deleted file mode 100644 index a2302668d..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/DropdownMenuPositionProvider.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupPositionProvider -import kotlin.math.max -import kotlin.math.min - -/** - * Copied from [material3](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Menu.kt?q=file:androidx%2Fcompose%2Fmaterial3%2FMenu.kt%20class:androidx.compose.material3.DropdownMenuPositionProvider). - */ -@Immutable -internal data class DropdownMenuPositionProvider( - val contentOffset: DpOffset, - val density: Density, - val onPositionCalculated: (anchorBounds: IntRect, menuBounds: IntRect) -> Unit = { _, _ -> }, -) : PopupPositionProvider { - - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - // The min margin above and below the menu, relative to the screen. - val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() } - // The content offset specified using the dropdown offset parameter. - val contentOffsetX = with(density) { contentOffset.x.roundToPx() } - val contentOffsetY = with(density) { contentOffset.y.roundToPx() } - - // Compute horizontal position. - val toRight = anchorBounds.left + contentOffsetX - val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width - val toDisplayRight = windowSize.width - popupContentSize.width - val toDisplayLeft = 0 - val x = if (layoutDirection == LayoutDirection.Ltr) { - sequenceOf( - toRight, - toLeft, - // If the anchor gets outside of the window on the left, we want to position - // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight. - if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft, - ) - } else { - sequenceOf( - toLeft, - toRight, - // If the anchor gets outside of the window on the right, we want to position - // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft. - if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight, - ) - }.firstOrNull { - it >= 0 && it + popupContentSize.width <= windowSize.width - } ?: toLeft - - // Compute vertical position. - val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin) - val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height - val toCenter = anchorBounds.top - popupContentSize.height / 2 - val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin - val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { - it >= verticalMargin && - it + popupContentSize.height <= windowSize.height - verticalMargin - } ?: toTop - - onPositionCalculated( - anchorBounds, - IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height), - ) - return IntOffset(x, y) - } -} - -internal val MenuVerticalMargin = 48.dp - -internal fun calculateTransformOrigin( - parentBounds: IntRect, - menuBounds: IntRect, -): TransformOrigin { - val pivotX = when { - menuBounds.left >= parentBounds.right -> 0f - menuBounds.right <= parentBounds.left -> 1f - menuBounds.width == 0 -> 0f - else -> { - val intersectionCenter = - ( - max(parentBounds.left, menuBounds.left) + - min(parentBounds.right, menuBounds.right) - ) / 2 - (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width - } - } - val pivotY = when { - menuBounds.top >= parentBounds.bottom -> 0f - menuBounds.bottom <= parentBounds.top -> 1f - menuBounds.height == 0 -> 0f - else -> { - val intersectionCenter = - ( - max(parentBounds.top, menuBounds.top) + - min(parentBounds.bottom, menuBounds.bottom) - ) / 2 - (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height - } - } - return TransformOrigin(pivotX, pivotY) -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/PopupProperties.kt b/app/src/main/java/com/jerboa/util/cascade/internal/PopupProperties.kt deleted file mode 100644 index 72ad3fadb..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/PopupProperties.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.window.PopupProperties - -@OptIn(ExperimentalComposeUiApi::class) -fun PopupProperties.copy( - usePlatformDefaultWidth: Boolean, -): PopupProperties { - return PopupProperties( - focusable = focusable, - dismissOnBackPress = dismissOnBackPress, - dismissOnClickOutside = dismissOnClickOutside, - securePolicy = securePolicy, - excludeFromSystemGesture = excludeFromSystemGesture, - clippingEnabled = clippingEnabled, - usePlatformDefaultWidth = usePlatformDefaultWidth, - ) -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt b/app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt deleted file mode 100644 index 620fb53f5..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/PositionPopupContent.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.jerboa.util.cascade.internal - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.absoluteOffset -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.draw.alpha -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.round -import androidx.compose.ui.unit.toOffset -import androidx.compose.ui.window.PopupPositionProvider -import kotlin.math.roundToInt - -@Composable -internal fun PositionPopupContent( - modifier: Modifier = Modifier, - positionProvider: PopupPositionProvider, - anchorBounds: ScreenRelativeBounds?, - content: @Composable () -> Unit, -) { - val popupView = LocalView.current - val layoutDirection = LocalLayoutDirection.current - var contentPosition: IntOffset? by remember { mutableStateOf(null) } - - Box(modifier) { - Box( - Modifier - .onGloballyPositioned { coordinates -> - val popupContentBounds = ScreenRelativeBounds(coordinates, owner = popupView) - - if (anchorBounds != null) { - contentPosition = positionProvider - .calculatePosition( - anchorBounds = anchorBounds.boundsInRoot.roundToIntRect(), - // material3 uses View#getWindowVisibleDisplayFrame() for calculating window size, - // but that produces infinite-like values for windows that have FLAG_LAYOUT_NO_LIMITS set. - // material3 ends up looking okay because WindowManager sanitizes bad values. - windowSize = anchorBounds.root.layoutBoundsInWindow.intSize(), - layoutDirection = layoutDirection, - popupContentSize = coordinates.size, - ) - .let { position -> - // Material3's DropdownMenuPositionProvider was written to calculate - // a position in the anchor's window. Cascade will have to adjust the - // position to use it inside the popup's window. - val positionInAnchorWindow = ScreenRelativeOffset( - positionInRoot = position.toOffset(), - root = anchorBounds.root, - ) - positionInAnchorWindow - .positionInWindowOf(popupContentBounds) - .round() - } - } - } - // Hide the popup until it can be positioned. - .alpha(if (contentPosition != null) 1f else 0f) - .absoluteOffset { contentPosition ?: IntOffset.Zero }, - ) { - content() - } - } -} - -private fun Rect.roundToIntRect(): IntRect { - return IntRect(topLeft = topLeft.round(), bottomRight = bottomRight.round()) -} - -private fun Rect.intSize(): IntSize { - return IntSize(width = width.roundToInt(), height = height.roundToInt()) -} diff --git a/app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt b/app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt deleted file mode 100644 index 5f3614e03..000000000 --- a/app/src/main/java/com/jerboa/util/cascade/internal/ScreenRelativeBounds.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.jerboa.util.cascade.internal - -import android.view.View -import androidx.compose.runtime.Immutable -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.findRootCoordinates -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.unit.toSize - -@Immutable -internal data class ScreenRelativeBounds( - val boundsInRoot: Rect, - val root: RootLayoutCoordinatesInfo, -) - -@Immutable -internal data class ScreenRelativeOffset( - val positionInRoot: Offset, - val root: RootLayoutCoordinatesInfo, -) - -@Immutable -internal data class RootLayoutCoordinatesInfo( - val layoutBoundsInWindow: Rect, - val windowPositionOnScreen: Offset, -) { - val layoutPositionInWindow: Offset get() = layoutBoundsInWindow.topLeft -} - -internal fun ScreenRelativeBounds(coordinates: LayoutCoordinates, owner: View): ScreenRelativeBounds { - return ScreenRelativeBounds( - boundsInRoot = Rect( - offset = coordinates.positionInRoot(), - size = coordinates.size.toSize(), - ), - root = RootLayoutCoordinatesInfo( - layoutBoundsInWindow = coordinates.findRootCoordinates().boundsInWindow(), - windowPositionOnScreen = run { - owner.rootView.getLocationOnScreen(intArrayBuffer) - Offset(x = intArrayBuffer[0].toFloat(), y = intArrayBuffer[1].toFloat()) - }, - ), - ) -} - -// I do not expect this to be shared across threads to need any synchronization. -private val intArrayBuffer = IntArray(size = 2) - -/** - * Calculate a position in another window such that its visual location on screen - * remains unchanged. That is, its offset from screen's 0,0 remains the same. - * */ -internal fun ScreenRelativeOffset.positionInWindowOf(other: ScreenRelativeBounds): Offset { - return positionInRoot - - (other.root.layoutPositionInWindow - root.layoutPositionInWindow) - - (other.root.windowPositionOnScreen - root.windowPositionOnScreen) -}