feat: add DropdownList

This commit is contained in:
2023-11-18 01:10:02 +08:00
parent 5450cc5512
commit c5994c0f2d
4 changed files with 481 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
/*
* Flexi UI - A flexible and useful UI component library.
* Copyright (C) 2019-2023 HighCapable
* https://github.com/BetterAndroid/FlexiUI
*
* Apache License Version 2.0
*
* 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.
*
* This file is created by fankes on 2023/11/18.
*/
@file:Suppress("unused")
package com.highcapable.flexiui.component
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.UiComposable
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.Role
import com.highcapable.flexiui.interaction.rippleClickable
import kotlin.math.max
@Composable
internal actual fun DropdownListBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
colors: DropdownListColors,
style: DropdownListStyle,
border: BorderStroke,
enabled: Boolean,
interactionSource: MutableInteractionSource,
menuHeightPx: (Int) -> Unit,
content: @Composable @UiComposable BoxWithConstraintsScope.() -> Unit
) {
val view = LocalView.current
val coordinates = remember { Ref<LayoutCoordinates>() }
BoxWithConstraints(
modifier = modifier.dropdownList(
colors = colors,
style = style,
border = border,
enabled = enabled,
interactionSource = interactionSource,
modifier = modifier.rippleClickable(
enabled = enabled,
role = Role.DropdownList,
interactionSource = interactionSource
) { onExpandedChange(!expanded) }.onGloballyPositioned {
coordinates.value = it
updateHeight(view.rootView, coordinates.value) { newHeight -> menuHeightPx(newHeight) }
}
),
content = content
)
DisposableEffect(view) {
val listener = OnGlobalLayoutListener(view) {
updateHeight(view.rootView, coordinates.value) { newHeight -> menuHeightPx(newHeight) }
}
onDispose { listener.dispose() }
}
}
private fun updateHeight(view: View, coordinates: LayoutCoordinates?, onHeightUpdate: (Int) -> Unit) {
coordinates ?: return
val visibleWindowBounds = Rect().let { view.getWindowVisibleDisplayFrame(it); it }
val heightAbove = coordinates.boundsInWindow().top - visibleWindowBounds.top
val heightBelow = visibleWindowBounds.bottom - visibleWindowBounds.top - coordinates.boundsInWindow().bottom
onHeightUpdate(max(heightAbove, heightBelow).toInt())
}
private class OnGlobalLayoutListener(
private val view: View,
private val onGlobalLayoutCallback: () -> Unit
) : View.OnAttachStateChangeListener, ViewTreeObserver.OnGlobalLayoutListener {
private var isListeningToGlobalLayout = false
init {
view.addOnAttachStateChangeListener(this)
registerOnGlobalLayoutListener()
}
override fun onViewAttachedToWindow(p0: View) = registerOnGlobalLayoutListener()
override fun onViewDetachedFromWindow(p0: View) = unregisterOnGlobalLayoutListener()
override fun onGlobalLayout() = onGlobalLayoutCallback()
private fun registerOnGlobalLayoutListener() {
if (isListeningToGlobalLayout || !view.isAttachedToWindow) return
view.viewTreeObserver.addOnGlobalLayoutListener(this)
isListeningToGlobalLayout = true
}
private fun unregisterOnGlobalLayoutListener() {
if (!isListeningToGlobalLayout) return
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
isListeningToGlobalLayout = false
}
fun dispose() {
unregisterOnGlobalLayoutListener()
view.removeOnAttachStateChangeListener(this)
}
}

View File

@@ -23,17 +23,33 @@
package com.highcapable.flexiui.component package com.highcapable.flexiui.component
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth 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.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -50,9 +66,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.UiComposable
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.InputMode
@@ -67,6 +87,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntRect
@@ -79,11 +100,23 @@ import com.highcapable.flexiui.LocalColors
import com.highcapable.flexiui.LocalShapes import com.highcapable.flexiui.LocalShapes
import com.highcapable.flexiui.LocalSizes import com.highcapable.flexiui.LocalSizes
import com.highcapable.flexiui.interaction.rippleClickable import com.highcapable.flexiui.interaction.rippleClickable
import com.highcapable.flexiui.resources.Icons
import com.highcapable.flexiui.resources.icon.Dropdown
import com.highcapable.flexiui.utils.borderOrNot
import com.highcapable.flexiui.utils.orElse import com.highcapable.flexiui.utils.orElse
import com.highcapable.flexiui.utils.solidColor
import com.highcapable.flexiui.utils.status import com.highcapable.flexiui.utils.status
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@Immutable
data class DropdownListColors(
val endIconTint: Color,
val borderInactiveColor: Color,
val borderActiveColor: Color,
val backgroundColor: Color
)
@Immutable @Immutable
data class DropdownMenuColors( data class DropdownMenuColors(
val contentColor: Color, val contentColor: Color,
@@ -91,6 +124,19 @@ data class DropdownMenuColors(
val borderColor: Color val borderColor: Color
) )
@Immutable
data class DropdownListStyle(
val padding: Dp,
val topPadding: Dp,
val startPadding: Dp,
val bottomPadding: Dp,
val endPadding: Dp,
val shape: Shape,
val endIconSize: Dp,
val borderInactive: BorderStroke,
val borderActive: BorderStroke
)
@Immutable @Immutable
data class DropdownMenuStyle( data class DropdownMenuStyle(
val inTransitionDuration: Int, val inTransitionDuration: Int,
@@ -99,6 +145,77 @@ data class DropdownMenuStyle(
val borderStyle: AreaBoxStyle val borderStyle: AreaBoxStyle
) )
@Composable
fun DropdownList(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
colors: DropdownListColors = DropdownList.colors,
style: DropdownListStyle = DropdownList.style,
menuColors: DropdownMenuColors = DropdownMenu.colors,
menuStyle: DropdownMenuStyle = DropdownMenu.style,
enabled: Boolean = true,
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
text: @Composable () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val focused by interactionSource.collectIsFocusedAsState()
val hovered by interactionSource.collectIsHoveredAsState()
var menuHeightPx by remember { mutableStateOf(0) }
val startPadding = style.startPadding.orElse() ?: style.padding
val endPadding = style.endPadding.orElse() ?: style.padding
val animatedBorderColor by animateColorAsState(when {
focused || hovered -> style.borderActive.solidColor
else -> style.borderInactive.solidColor
})
val animatedDirection by animateFloatAsState(if (expanded) 180f else 0f)
val animatedBorderWidth by animateDpAsState(when {
focused -> style.borderActive.width
else -> style.borderInactive.width
})
val border = when {
focused || hovered -> style.borderInactive
else -> style.borderInactive
}.copy(animatedBorderWidth, SolidColor(animatedBorderColor))
DropdownListBox(
expanded = expanded,
onExpandedChange = onExpandedChange,
modifier = modifier,
colors = colors,
style = style,
border = border,
enabled = enabled,
interactionSource = interactionSource,
menuHeightPx = { menuHeightPx = it }
) {
val menuWidth = maxWidth + startPadding + endPadding
val menuHeight = with(LocalDensity.current) { menuHeightPx.toDp() }
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Box(modifier = Modifier.weight(1f)) { text() }
Icon(
modifier = Modifier.graphicsLayer {
rotationZ = animatedDirection
}.size(style.endIconSize),
imageVector = Icons.Dropdown,
tint = colors.endIconTint
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) },
offset = DefaultDropdownListMenuOffset,
modifier = Modifier.width(menuWidth).heightIn(max = menuHeight),
colors = menuColors,
style = menuStyle,
scrollState = scrollState,
properties = properties,
content = content
)
}
}
@Composable @Composable
fun DropdownMenu( fun DropdownMenu(
expanded: Boolean, expanded: Boolean,
@@ -233,6 +350,41 @@ private fun DropdownMenuContent(
} }
} }
@Composable
internal expect fun DropdownListBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
colors: DropdownListColors,
style: DropdownListStyle,
border: BorderStroke,
enabled: Boolean,
interactionSource: MutableInteractionSource,
menuHeightPx: (Int) -> Unit,
content: @Composable @UiComposable BoxWithConstraintsScope.() -> Unit
)
internal fun Modifier.dropdownList(
colors: DropdownListColors,
style: DropdownListStyle,
border: BorderStroke,
enabled: Boolean,
interactionSource: MutableInteractionSource,
modifier: Modifier
) = status(enabled)
.focusable(enabled, interactionSource)
.hoverable(interactionSource, enabled)
.clip(style.shape)
.background(colors.backgroundColor, style.shape)
.borderOrNot(border, style.shape)
.then(modifier)
.padding(
top = style.topPadding.orElse() ?: style.padding,
start = style.startPadding.orElse() ?: style.padding,
bottom = style.bottomPadding.orElse() ?: style.padding,
end = style.endPadding.orElse() ?: style.padding
)
private fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect): TransformOrigin { private fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect): TransformOrigin {
val pivotX = when { val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f menuBounds.left >= parentBounds.right -> 0f
@@ -312,6 +464,17 @@ private data class DropdownMenuPositionProvider(
} }
} }
object DropdownList {
val colors: DropdownListColors
@Composable
@ReadOnlyComposable
get() = defaultDropdownListColors()
val style: DropdownListStyle
@Composable
@ReadOnlyComposable
get() = defaultDropdownListStyle()
}
object DropdownMenu { object DropdownMenu {
val colors: DropdownMenuColors val colors: DropdownMenuColors
@Composable @Composable
@@ -329,6 +492,15 @@ private val LocalDropdownMenuActiveColor = compositionLocalOf { Color.Unspecifie
private val LocalDropdownMenuContentStyle = compositionLocalOf<AreaBoxStyle?> { null } private val LocalDropdownMenuContentStyle = compositionLocalOf<AreaBoxStyle?> { null }
@Composable
@ReadOnlyComposable
private fun defaultDropdownListColors() = DropdownListColors(
endIconTint = LocalColors.current.themeSecondary,
borderInactiveColor = LocalColors.current.themeSecondary,
borderActiveColor = LocalColors.current.themePrimary,
backgroundColor = Color.Transparent
)
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
private fun defaultDropdownMenuColors() = DropdownMenuColors( private fun defaultDropdownMenuColors() = DropdownMenuColors(
@@ -337,6 +509,23 @@ private fun defaultDropdownMenuColors() = DropdownMenuColors(
borderColor = AreaBox.color borderColor = AreaBox.color
) )
@Composable
@ReadOnlyComposable
private fun defaultDropdownListStyle() = DropdownListStyle(
padding = LocalSizes.current.spacingSecondary,
topPadding = Dp.Unspecified,
startPadding = Dp.Unspecified,
bottomPadding = Dp.Unspecified,
endPadding = Dp.Unspecified,
shape = when (LocalInAreaBox.current) {
true -> LocalAreaBoxShape.current
else -> LocalShapes.current.secondary
},
endIconSize = LocalSizes.current.iconSizeTertiary,
borderInactive = defaultDropdownListInactiveBorder(),
borderActive = defaultDropdownListActiveBorder()
)
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
private fun defaultDropdownMenuStyle() = DropdownMenuStyle( private fun defaultDropdownMenuStyle() = DropdownMenuStyle(
@@ -355,6 +544,16 @@ private fun defaultDropdownMenuStyle() = DropdownMenuStyle(
) )
) )
@Composable
@ReadOnlyComposable
private fun defaultDropdownListInactiveBorder() = BorderStroke(LocalSizes.current.borderSizeSecondary, LocalColors.current.themeSecondary)
@Composable
@ReadOnlyComposable
private fun defaultDropdownListActiveBorder() = BorderStroke(LocalSizes.current.borderSizePrimary, LocalColors.current.themePrimary)
private val DefaultDropdownListMenuOffset = DpOffset((-10).dp, 10.dp)
private val DefaultMenuContentPadding = 16.dp private val DefaultMenuContentPadding = 16.dp
private const val DefaultInTransitionDuration = 120 private const val DefaultInTransitionDuration = 120

View File

@@ -0,0 +1,76 @@
/*
* Flexi UI - A flexible and useful UI component library.
* Copyright (C) 2019-2023 HighCapable
* https://github.com/BetterAndroid/FlexiUI
*
* Apache License Version 2.0
*
* 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.
*
* This file is created by fankes on 2023/11/18.
*/
@file:Suppress("unused")
package com.highcapable.flexiui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.UiComposable
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.toIntRect
import com.highcapable.flexiui.interaction.rippleClickable
import kotlin.math.max
@Composable
internal actual fun DropdownListBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
colors: DropdownListColors,
style: DropdownListStyle,
border: BorderStroke,
enabled: Boolean,
interactionSource: MutableInteractionSource,
menuHeightPx: (Int) -> Unit,
content: @Composable @UiComposable BoxWithConstraintsScope.() -> Unit
) {
val windowInfo = LocalWindowInfo.current
BoxWithConstraints(
modifier = modifier.dropdownList(
colors = colors,
style = style,
border = border,
enabled = enabled,
interactionSource = interactionSource,
modifier = modifier.rippleClickable(
enabled = enabled,
role = Role.DropdownList,
interactionSource = interactionSource
) { onExpandedChange(!expanded) }.onGloballyPositioned {
val boundsInWindow = it.boundsInWindow()
val visibleWindowBounds = windowInfo.containerSize.toIntRect()
val heightAbove = boundsInWindow.top - visibleWindowBounds.top
val heightBelow = visibleWindowBounds.height - boundsInWindow.bottom
menuHeightPx(max(heightAbove, heightBelow).toInt())
}
),
content = content
)
}

View File

@@ -0,0 +1,76 @@
/*
* Flexi UI - A flexible and useful UI component library.
* Copyright (C) 2019-2023 HighCapable
* https://github.com/BetterAndroid/FlexiUI
*
* Apache License Version 2.0
*
* 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.
*
* This file is created by fankes on 2023/11/18.
*/
@file:Suppress("unused")
package com.highcapable.flexiui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.UiComposable
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.toIntRect
import com.highcapable.flexiui.interaction.rippleClickable
import kotlin.math.max
@Composable
internal actual fun DropdownListBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
colors: DropdownListColors,
style: DropdownListStyle,
border: BorderStroke,
enabled: Boolean,
interactionSource: MutableInteractionSource,
menuHeightPx: (Int) -> Unit,
content: @Composable @UiComposable BoxWithConstraintsScope.() -> Unit
) {
val windowInfo = LocalWindowInfo.current
BoxWithConstraints(
modifier = modifier.dropdownList(
colors = colors,
style = style,
border = border,
enabled = enabled,
interactionSource = interactionSource,
modifier = modifier.rippleClickable(
enabled = enabled,
role = Role.DropdownList,
interactionSource = interactionSource
) { onExpandedChange(!expanded) }.onGloballyPositioned {
val boundsInWindow = it.boundsInWindow()
val visibleWindowBounds = windowInfo.containerSize.toIntRect()
val heightAbove = boundsInWindow.top - visibleWindowBounds.top
val heightBelow = visibleWindowBounds.height - boundsInWindow.bottom
menuHeightPx(max(heightAbove, heightBelow).toInt())
}
),
content = content
)
}