/*
* 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
*
* 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 androidx.wear.compose.foundation
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.util.lerp
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Create and [remember] an [ExpandableState]
*
* Example of an expandable list:
* @sample androidx.wear.compose.foundation.samples.ExpandableWithItemsSample
*
* Example of an expandable text:
* @sample androidx.wear.compose.foundation.samples.ExpandableTextSample
*
* @param initiallyExpanded The initial value of the state.
* @param expandAnimationSpec The [AnimationSpec] to use when showing the extra information.
* @param collapseAnimationSpec The [AnimationSpec] to use when hiding the extra information.
*/
@Composable
public fun rememberExpandableState(
initiallyExpanded: Boolean = false,
expandAnimationSpec: AnimationSpec<Float> = ExpandableItemsDefaults.expandAnimationSpec,
collapseAnimationSpec: AnimationSpec<Float> = ExpandableItemsDefaults.collapseAnimationSpec,
): ExpandableState {
val scope = rememberCoroutineScope()
return remember {
ExpandableState(initiallyExpanded, scope, expandAnimationSpec, collapseAnimationSpec)
}
}
/**
* Adds a series of items, that will be expanded/collapsed according to the [ExpandableState]
*
* Example of an expandable list:
* @sample androidx.wear.compose.foundation.samples.ExpandableWithItemsSample
*
* @param state The [ExpandableState] connected to these items.
* @param count The number of items
* @param key a factory of stable and unique keys representing the item. Using the same key
* for multiple items in the list is not allowed. Type of the key should be saveable
* via Bundle on Android. If null is passed the position in the list will represent the key.
* When you specify the key the scroll position will be maintained based on the key, which
* means if you add/remove items before the current visible item the item with the given key
* will be kept as the first visible one.
* @param itemContent the content displayed by a single item
*/
public fun ScalingLazyListScope.expandableItems(
state: ExpandableState,
count: Int,
key: ((index: Int) -> Any)? = null,
itemContent: @Composable BoxScope.(index: Int) -> Unit
) {
repeat(count) { itemIndex ->
// Animations for each item start in inverse order, the first item animates last.
val animationStart = count - 1 - itemIndex
val animationProgress =
(state.expandProgress * count - animationStart).coerceIn(0f, 1f)
if (animationProgress > 0) {
item(key = key?.invoke(itemIndex)) {
Layout(
modifier = Modifier.clipToBounds(),
content = { Box(content = { itemContent(itemIndex) }) }
) { measurables, constraints ->
val placeable = measurables.first().measure(constraints)
val shownHeight = (placeable.height * animationProgress).roundToInt()
layout(placeable.width, shownHeight) {
val y = (placeable.height * (animationProgress - 1)).roundToInt()
placeable.placeWithLayer(0, y)
}
}
}
}
}
}
/**
* Adds a single item, that will be expanded/collapsed according to the [ExpandableState].
*
* Example of an expandable text:
* @sample androidx.wear.compose.foundation.samples.ExpandableTextSample
*
* The item should support two levels of information display (for example, a text showing a few
* lines in the collapsed state, and more in the expanded state)
*
* @param state The [ExpandableState] connected to this item.
* @param key A stable and unique key representing the item. Using the same key
* for multiple items in the list is not allowed. Type of the key should be saveable
* via Bundle on Android. If null is passed the position in the list will represent the key.
* When you specify the key the scroll position will be maintained based on the key, which
* means if you add/remove items before the current visible item the item with the given key
* will be kept as the first visible one.
* @param content the content displayed by the item, according to its expanded/collapsed state.
*/
public fun ScalingLazyListScope.expandableItem(
state: ExpandableState,
key: Any? = null,
content: @Composable (expanded: Boolean) -> Unit
) = expandableItemImpl(state, key, content = content)
/**
* Adds a single item, for the button that controls expandable item(s). The button will be animated
* out when the corresponding expandables are expanded.
*
* Example of an expandable text:
* @sample androidx.wear.compose.foundation.samples.ExpandableTextSample
*
* @param state The [ExpandableState] to connect this button to.
* @param key A stable and unique key representing the item. Using the same key
* for multiple items in the list is not allowed. Type of the key should be saveable
* via Bundle on Android. If null is passed the position in the list will represent the key.
* When you specify the key the scroll position will be maintained based on the key, which
* means if you add/remove items before the current visible item the item with the given key
* will be kept as the first visible one.
* @param content the content displayed, this should usually be a CompactChip or OutlineCompactChip.
*/
public fun ScalingLazyListScope.expandableButton(
state: ExpandableState,
key: Any? = null,
content: @Composable () -> Unit
) = expandableItemImpl(state, key, invertProgress = true, content = { if (it) content() })
private fun ScalingLazyListScope.expandableItemImpl(
state: ExpandableState,
key: Any? = null,
invertProgress: Boolean = false,
content: @Composable (expanded: Boolean) -> Unit
) {
val progress = if (invertProgress) 1f - state.expandProgress else state.expandProgress
item(key = key) {
Layout(
content = {
Box { content(false) }
Box { content(true) }
},
modifier = Modifier.clipToBounds()
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val width = lerp(placeables[0].width, placeables[1].width, progress)
val height = lerp(placeables[0].height, placeables[1].height, progress)
// Keep the items horizontally centered.
val off0 = (width - placeables[0].width) / 2
val off1 = (width - placeables[1].width) / 2
layout(width, height) {
placeables[0].placeWithLayer(off0, 0) { alpha = 1 - progress }
placeables[1].placeWithLayer(off1, 0) { alpha = progress }
}
}
}
}
/**
* State of the Expandable composables.
*
* It's used to control the showing/hiding of extra information either directly or connecting it
* with something like a button.
*/
public class ExpandableState internal constructor(
initiallyExpanded: Boolean,
private val coroutineScope: CoroutineScope,
private val expandAnimationSpec: AnimationSpec<Float>,
private val collapseAnimationSpec: AnimationSpec<Float>,
) {
private val _expandProgress = Animatable(if (initiallyExpanded) 1f else 0f)
/**
* While in the middle of the animation, this represents the progress from 0f (collapsed) to
* 1f (expanded), or the other way around.
* If no animation is running, it's either 0f if the extra content is not showing, or 1f if
* the extra content is showing.
*/
val expandProgress
get() = _expandProgress.value
/**
* Represents the current state of the component, true means it's showing the extra information.
* If its in the middle of an animation, the value of this field takes into account only the
* target of that animation.
*
* Modifying this value triggers a change to show/hide the extra information.
*/
var expanded
@JvmName("isExpanded")
get() = _expandProgress.targetValue == 1f
set(newValue) {
if (expanded != newValue) {
coroutineScope.launch {
if (newValue) {
_expandProgress.animateTo(1f, expandAnimationSpec)
} else {
_expandProgress.animateTo(0f, collapseAnimationSpec)
}
}
}
}
}
/**
* Contains the default values used by Expandable components.
*/
public object ExpandableItemsDefaults {
/**
* Default animation used to show extra information.
*/
val expandAnimationSpec: AnimationSpec<Float> = TweenSpec(1000)
/**
* Default animation used to hide extra information.
*/
val collapseAnimationSpec: AnimationSpec<Float> = TweenSpec(1000)
}