Use disclosures when content should stay collapsed until the user asks to reveal more.
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
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.rotate
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
import com.composables.ui.components.Disclosure
import com.composables.ui.components.DisclosureButton
import com.composables.ui.components.DisclosurePanel
import com.composables.ui.components.Icon
import com.composables.ui.components.Text
@Composable
fun DisclosureExample() {
var expanded by remember { mutableStateOf(false) }
Disclosure(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
DisclosureButton(modifier = Modifier.fillMaxWidth()) {
Text("What is your return policy?")
}
DisclosurePanel {
Text("Returns are accepted within 30 days in original condition. Refunds are issued to the original payment method.")
}
}
}Installation
implementation("com.composables:ui:0.1.0")Add the required dependencies
implementation("com.composables:composeunstyled:2.7.0")
Copy and paste the following sources into your project
components/Disclosure.kt
package com.composables.ui.components
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.ui.theme.InteractionMode
import com.composables.ui.theme.LocalInteractionMode
import com.composables.ui.theme.alphas
import com.composables.ui.theme.colors
import com.composables.ui.theme.defaultIndication
import com.composables.ui.theme.disabledAlpha
import com.composables.ui.theme.ringColor
import com.composables.ui.theme.indications
import com.composables.ui.theme.mediumShape
import com.composables.ui.theme.shapes
import com.composeunstyled.UnstyledDisclosedContent
import com.composeunstyled.UnstyledDisclosure
import com.composeunstyled.UnstyledDisclosureButton
import com.composeunstyled.theme.Theme
/**
* A container that coordinates disclosure state and content.
* @param expanded Whether the disclosure content is expanded.
* @param onExpandedChange Called when the disclosure expanded state changes.
* @param modifier Modifier applied to the component.
* @param content Composable content displayed by the component.
*/
@Composable
fun Disclosure(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
UnstyledDisclosure(
expanded = expanded,
onExpandedChange = onExpandedChange,
modifier = modifier,
) {
CompositionLocalProvider(LocalDisclosureExpanded provides expanded) {
Column {
content()
}
}
}
}
/**
* A button that toggles the disclosure between expanded and collapsed.
* @param modifier Modifier applied to the component.
* @param enabled Whether the disclosure button can be interacted with.
* @param contentPadding Padding applied inside the component content.
* @param indicator Optional trailing indicator that receives the expanded state.
* @param interactionSource Interaction source used for focus and press state.
* @param content Composable content displayed by the component.
*/
@Composable
fun DisclosureButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
indicator: (@Composable (expanded: Boolean) -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit,
) {
val expanded = LocalDisclosureExpanded.current
val shape = Theme[shapes][mediumShape]
val alpha = if (enabled) 1f else Theme[alphas][disabledAlpha]
UnstyledDisclosureButton(
modifier = modifier
.focusRing(
interactionSource = interactionSource,
color = Theme[colors][ringColor],
shape = shape,
)
.bouncyPress(
interactionSource = interactionSource,
enabled = enabled,
)
.alpha(alpha)
.clip(shape),
enabled = enabled,
contentPadding = PaddingValues(0.dp),
indication = Theme[indications][defaultIndication],
interactionSource = interactionSource,
contentAlignment = Alignment.CenterStart,
) {
Row(
modifier = Modifier
.heightIn(min = disclosureButtonMinHeight())
.padding(contentPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
content()
if (indicator != null) {
Spacer(Modifier.weight(1f))
indicator(expanded)
}
}
}
}
/**
* The expandable content region of a disclosure.
* @param modifier Modifier applied to the component.
* @param contentPadding Padding applied inside the component content.
* @param enter Transition used when the disclosure panel expands.
* @param exit Transition used when the disclosure panel collapses.
* @param content Composable content displayed by the component.
*/
@Composable
fun DisclosurePanel(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(12.dp),
enter: EnterTransition = expandVertically(animationSpec = spring()) + fadeIn(),
exit: ExitTransition = shrinkVertically(animationSpec = spring()) + fadeOut(),
content: @Composable () -> Unit,
) {
UnstyledDisclosedContent(
modifier = modifier,
enter = enter,
exit = exit,
) {
Box(
Modifier
.padding(contentPadding),
) {
content()
}
}
}
private val LocalDisclosureExpanded = staticCompositionLocalOf { false }
@Composable
private fun disclosureButtonMinHeight(): Dp {
return if (LocalInteractionMode.current == InteractionMode.Touch) 48.dp else 36.dp
}components/Utils.kt
package com.composables.ui.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.ui.theme.colors
import com.composables.ui.theme.ringColor
import com.composeunstyled.FocusRingVisibility
import com.composeunstyled.collectIsFocusVisibleAsState
import com.composeunstyled.outline
import com.composeunstyled.theme.Theme
@Composable
fun Modifier.focusRing(
interactionSource: InteractionSource,
width: Dp = 2.dp,
color: Color = Theme[colors][ringColor],
shape: Shape = RectangleShape,
offset: Dp = 0.dp,
visibility: FocusRingVisibility = FocusRingVisibility.FocusVisible,
): Modifier {
val showFocusRing by if (visibility == FocusRingVisibility.FocusVisible) {
interactionSource.collectIsFocusVisibleAsState()
} else {
interactionSource.collectIsFocusedAsState()
}
val animatedWidth by animateDpAsState(
targetValue = if (showFocusRing) width else 0.dp,
animationSpec = tween(durationMillis = 120),
label = "FocusRingWidth",
)
return this then Modifier.outline(
width = animatedWidth,
color = color,
shape = shape,
offset = offset,
)
}
@Composable
fun Modifier.bouncyPress(
interactionSource: InteractionSource,
enabled: Boolean = true,
pressedScale: Float = 0.98f,
): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (enabled && pressed) pressedScale else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
label = "BouncyPressScale",
)
return this then Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
}
}Examples
With an indicator
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
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.rotate
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Lucide
import com.composables.ui.components.Disclosure
import com.composables.ui.components.DisclosureButton
import com.composables.ui.components.DisclosurePanel
import com.composables.ui.components.Icon
import com.composables.ui.components.Text
@Composable
fun DisclosureWithIndicatorExample() {
var expanded by remember { mutableStateOf(false) }
Disclosure(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
DisclosureButton(
indicator = {
val rotation by animateFloatAsState(
targetValue = if (it) -180f else 0f,
)
Icon(
imageVector = Lucide.ChevronDown,
modifier = Modifier
.size(16.dp)
.rotate(rotation),
)
},
modifier = Modifier.fillMaxWidth()
) {
Text("What is your return policy?")
}
DisclosurePanel {
Text("Returns are accepted within 30 days in original condition. Refunds are issued to the original payment method.")
}
}
}Disabled
import androidx.compose.foundation.layout.fillMaxWidth
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 com.composables.ui.components.Disclosure
import com.composables.ui.components.DisclosureButton
import com.composables.ui.components.DisclosurePanel
import com.composables.ui.components.Text
@Composable
fun DisabledDisclosureExample() {
var expanded by remember { mutableStateOf(false) }
Disclosure(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
DisclosureButton(enabled = false, modifier = Modifier.fillMaxWidth()) {
Text("Show installation notes")
}
DisclosurePanel {
Text("Wall mounting requires a solid surface, a level, and two anchor points spaced 40 cm apart.")
}
}
}API Reference
Disclosure
A container that coordinates disclosure state and content.
@Composable
fun Disclosure(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)
| Parameter | Type | Description |
|---|---|---|
expanded |
Boolean |
Whether the disclosure content is expanded. |
onExpandedChange |
(Boolean) -> Unit |
Called when the disclosure expanded state changes. |
modifier |
Modifier |
Modifier applied to the component. |
content |
@Composable () -> Unit |
Composable content displayed by the component. |
DisclosureButton
A button that toggles the disclosure between expanded and collapsed.
@Composable
fun DisclosureButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
indicator: (@Composable (expanded: Boolean) -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit,
)
| Parameter | Type | Description |
|---|---|---|
modifier |
Modifier |
Modifier applied to the component. |
enabled |
Boolean |
Whether the disclosure button can be interacted with. |
contentPadding |
PaddingValues |
Padding applied inside the component content. |
indicator |
(@Composable (expanded: Boolean) -> Unit)? |
Optional trailing indicator that receives the expanded state. |
interactionSource |
MutableInteractionSource |
Interaction source used for focus and press state. |
content |
@Composable RowScope.() -> Unit |
Composable content displayed by the component. |
DisclosurePanel
The expandable content region of a disclosure.
@Composable
fun DisclosurePanel(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(12.dp),
enter: EnterTransition = expandVertically(animationSpec = spring()) + fadeIn(),
exit: ExitTransition = shrinkVertically(animationSpec = spring()) + fadeOut(),
content: @Composable () -> Unit,
)
| Parameter | Type | Description |
|---|---|---|
modifier |
Modifier |
Modifier applied to the component. |
contentPadding |
PaddingValues |
Padding applied inside the component content. |
enter |
EnterTransition |
Transition used when the disclosure panel expands. |
exit |
ExitTransition |
Transition used when the disclosure panel collapses. |
content |
@Composable () -> Unit |
Composable content displayed by the component. |