initial changes for anchored draggable

This commit is contained in:
Joyeeta 2024-09-19 17:30:08 +05:30
Родитель 3776ab4a13
Коммит 3b017e90ab
10 изменённых файлов: 1274 добавлений и 69 удалений

Просмотреть файл

@ -29,8 +29,8 @@ import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.token.FluentAliasTokens
import com.microsoft.fluentui.tokenized.controls.RadioButton
import com.microsoft.fluentui.tokenized.controls.ToggleSwitch
import com.microsoft.fluentui.tokenized.drawer.BottomDrawer
import com.microsoft.fluentui.tokenized.drawer.rememberBottomDrawerState
import com.microsoft.fluentui.tokenized.drawer.BottomDrawerV2
import com.microsoft.fluentui.tokenized.drawer.rememberBottomDrawerStateV2
import com.microsoft.fluentui.tokenized.listitem.ListItem
import com.microsoft.fluentuidemo.R
import com.microsoft.fluentuidemo.V2DemoActivity
@ -412,7 +412,7 @@ private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt(
) {
val scope = rememberCoroutineScope()
val drawerState = rememberBottomDrawerState(expandable = expandable, skipOpenState = skipOpenState)
val drawerState = rememberBottomDrawerStateV2(expandable = expandable, skipOpenState = skipOpenState)
val open: () -> Unit = {
scope.launch { drawerState.open() }
@ -435,7 +435,7 @@ private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt(
)
}
BottomDrawer(
BottomDrawerV2(
drawerState = drawerState,
drawerContent = { drawerContent(close) },
scrimVisible = scrimVisible,

Просмотреть файл

@ -35,8 +35,8 @@ import com.microsoft.fluentui.tokenized.controls.Button
import com.microsoft.fluentui.tokenized.controls.Label
import com.microsoft.fluentui.tokenized.controls.RadioButton
import com.microsoft.fluentui.tokenized.controls.ToggleSwitch
import com.microsoft.fluentui.tokenized.drawer.Drawer
import com.microsoft.fluentui.tokenized.drawer.rememberDrawerState
import com.microsoft.fluentui.tokenized.drawer.DrawerV2
import com.microsoft.fluentui.tokenized.drawer.rememberDrawerStateV2
import com.microsoft.fluentui.tokenized.listitem.ListItem
import com.microsoft.fluentuidemo.R
import com.microsoft.fluentuidemo.V2DemoActivity
@ -383,7 +383,7 @@ private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt(
preventDismissalOnScrimClick: Boolean
) {
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState()
val drawerState = rememberDrawerStateV2()
val open: () -> Unit = {
scope.launch { drawerState.open() }
}
@ -405,7 +405,7 @@ private fun CreateDrawerWithButtonOnPrimarySurfaceToInvokeIt(
)
}
Drawer(
DrawerV2(
drawerState = drawerState,
offset = offset,
drawerContent = { drawerContent(close) },

Просмотреть файл

@ -67,6 +67,7 @@ allprojects {
tokenautocompleteVersion = '2.0.8'
threetenabpVersion = '1.1.0'
universalPkgDir = "universal"
anchoredDraggableVersion = '1.6.0-alpha01'
}
repositories {
google()

Просмотреть файл

@ -69,6 +69,7 @@ dependencies {
implementation "com.microsoft.device:dualscreen-layout:$duoVersion"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"
implementation "androidx.compose.material:material"
implementation "androidx.compose.foundation:foundation:$anchoredDraggableVersion"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.robolectric:robolectric:$robolectric"

Просмотреть файл

@ -0,0 +1,322 @@
package com.microsoft.fluentui.tokenized.drawer
import android.content.Context
import android.content.res.Configuration
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.microsoft.fluentui.drawer.R
import com.microsoft.fluentui.theme.token.Icon
import com.microsoft.fluentui.tokenized.calculateFraction
import com.microsoft.fluentui.util.pxToDp
import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.drawerHeight(
slideOver: Boolean,
fixedHeight: Float,
fullHeight: Float,
drawerState: DrawerStateV2
): Modifier {
val modifier = if (slideOver) {
if (drawerState.expandable) {
Modifier
} else {
Modifier.heightIn(
0.dp,
pxToDp(fixedHeight)
)
}
} else {
Modifier.height(pxToDp(fullHeight - drawerState.anchoredDraggableState.offset))
}
return this.then(modifier)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BottomDrawerV2(
modifier: Modifier,
drawerState: DrawerStateV2,
drawerShape: Shape,
drawerElevation: Dp,
drawerBackground: Brush,
drawerHandleColor: Color,
scrimColor: Color,
scrimVisible: Boolean,
slideOver: Boolean,
enableSwipeDismiss: Boolean = true,
showHandle: Boolean,
onDismiss: () -> Unit,
drawerContent: @Composable () -> Unit,
maxLandscapeWidthFraction : Float = 1F,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {}
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight.toFloat()
val drawerHeight =
remember(drawerContent.hashCode()) { mutableStateOf<Float?>(null) }
val maxOpenHeight = fullHeight * DrawerOpenFraction
val drawerConstraints = with(LocalDensity.current) {
Modifier
.sizeIn(
maxWidth = constraints.maxWidth.toDp(),
maxHeight = constraints.maxHeight.toDp()
)
}
val scope = rememberCoroutineScope()
val drawerStateAnchors = drawerState.anchoredDraggableState.anchors
val drawerStateOffset = drawerState.anchoredDraggableState.offset
Scrim(
open = !drawerState.isClosed || (drawerHeight != null && drawerHeight.value == 0f),
onClose = onDismiss,
fraction = {
if (drawerStateAnchors.size == 0 || (drawerHeight != null && drawerHeight.value == 0f)) {
0.toFloat()
} else {
val targetValue: DrawerValue = if (slideOver) {
drawerStateAnchors.closestAnchor(drawerStateAnchors.maxAnchor())!!
} else if (drawerState.skipOpenState) {
DrawerValue.Expanded
} else {
DrawerValue.Open
}
calculateFraction(
drawerStateAnchors.positionOf(DrawerValue.Closed),
drawerStateAnchors.positionOf(targetValue),
drawerStateOffset
)
}
},
color = if (scrimVisible) scrimColor else Color.Transparent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
onScrimClick = onScrimClick
)
val configuration = LocalConfiguration.current
Box(
drawerConstraints
.fillMaxWidth(
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) maxLandscapeWidthFraction
else 1F
)
// .nestedScroll(
// if (!enableSwipeDismiss && drawerStateOffset >= maxOpenHeight) drawerState.NonDismissiblePreUpPostDownNestedScrollConnection else
// if (slideOver) drawerState.nestedScrollConnection else drawerState.PostDownNestedScrollConnection
// )
.offset {
val y = if (drawerStateAnchors.size == 0) {
fullHeight.roundToInt()
} else {
drawerStateOffset.roundToInt()
}
IntOffset(x = 0, y = y)
}
.then(
if (maxLandscapeWidthFraction != 1F
&& configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
) Modifier.align(Alignment.TopCenter)
else Modifier
)
.onGloballyPositioned { layoutCoordinates ->
if (!drawerState.animationInProgress
&& drawerState.anchoredDraggableState.currentValue == DrawerValue.Closed
&& drawerState.anchoredDraggableState.targetValue == DrawerValue.Closed
) {
onDismiss()
}
if (slideOver) {
val originalSize = layoutCoordinates.size.height.toFloat()
drawerHeight.value = if (drawerState.expandable) {
originalSize
} else {
min(
originalSize,
maxOpenHeight
)
}
}
}
.bottomDrawerAnchoredDraggable(
drawerState,
slideOver,
maxOpenHeight,
fullHeight,
drawerHeight.value
)
.drawerHeight(
slideOver,
maxOpenHeight,
fullHeight,
drawerState
)
.shadow(drawerElevation)
.clip(drawerShape)
.background(drawerBackground)
.semantics {
if (!drawerState.isClosed) {
dismiss {
onDismiss()
true
}
if (drawerState.anchoredDraggableState.currentValue == DrawerValue.Open && drawerState.hasExpandedState) {
expand {
if (drawerState.confirmValueChange(DrawerValue.Expanded)) {
scope.launch { drawerState.expand() }
}
true
}
} else if (drawerState.hasExpandedState && drawerState.hasOpenedState) {
collapse {
if (drawerState.confirmValueChange(DrawerValue.Open)) {
scope.launch { drawerState.open() }
}
true
}
}
}
}
.focusable(false),
) {
Column {
if (showHandle) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxWidth()
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
if (!enableSwipeDismiss && drawerStateOffset >= maxOpenHeight) {
if (delta < 0) {
drawerState.anchoredDraggableState.dispatchRawDelta(delta)
}
} else {
drawerState.anchoredDraggableState.dispatchRawDelta(delta)
}
},
onDragStopped = { velocity ->
launch {
drawerState.anchoredDraggableState.settle(
velocity
)
if (drawerState.isClosed) {
if (enableSwipeDismiss)
onDismiss()
else
scope.launch { drawerState.open() }
}
}
},
)
.testTag(DRAWER_HANDLE_TAG)
) {
val collapsed = LocalContext.current.resources.getString(R.string.collapsed)
val expanded = LocalContext.current.resources.getString(R.string.expanded)
val accessibilityManager = LocalContext.current.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
Icon(
painterResource(id = R.drawable.ic_drawer_handle),
contentDescription = LocalContext.current.resources.getString(R.string.drag_handle),
tint = drawerHandleColor,
modifier = Modifier
.clickable(
enabled = drawerState.hasExpandedState,
role = Role.Button,
onClickLabel =
if (drawerState.anchoredDraggableState.currentValue == DrawerValue.Expanded) {
LocalContext.current.resources.getString(R.string.collapse)
} else {
if (drawerState.hasExpandedState && !drawerState.isClosed) LocalContext.current.resources.getString(
R.string.expand
) else null
}
) {
if (drawerState.anchoredDraggableState.currentValue == DrawerValue.Expanded) {
if (drawerState.hasOpenedState && drawerState.confirmValueChange(
DrawerValue.Open
)
) {
scope.launch { drawerState.open() }
accessibilityManager?.let { manager ->
if(manager.isEnabled){
val event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_ANNOUNCEMENT).apply {
text.add(collapsed)
}
manager.sendAccessibilityEvent(event)
}
}
}
} else if (drawerState.hasExpandedState) {
if (drawerState.confirmValueChange(DrawerValue.Expanded)) {
scope.launch { drawerState.expand() }
accessibilityManager?.let { manager ->
if(manager.isEnabled){
val event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_ANNOUNCEMENT).apply {
text.add(expanded)
}
manager.sendAccessibilityEvent(event)
}
}
}
}
}
)
}
}
Column(modifier = Modifier
.testTag(DRAWER_CONTENT_TAG), content = { drawerContent() })
}
}
}
}

Просмотреть файл

@ -348,67 +348,6 @@ fun rememberBottomDrawerState(
}
}
private class DrawerPositionProvider(val offset: IntOffset?) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
if (offset != null) {
return IntOffset(anchorBounds.left + offset.x, anchorBounds.top + offset.y)
}
return IntOffset(0, 0)
}
}
@Composable
private fun Scrim(
open: Boolean,
onClose: () -> Unit,
fraction: () -> Float,
color: Color,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {},
) {
val dismissDrawer = if (open) {
Modifier.pointerInput(onClose) {
detectTapGestures {
if (!preventDismissalOnScrimClick) {
onClose()
}
onScrimClick() //this function runs post onClose() so that the drawer is closed before the callback is invoked
}
}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissDrawer)
.testTag(DRAWER_SCRIM_TAG)
) {
drawRect(color, alpha = fraction())
}
}
private val EndDrawerPadding = 56.dp
private val DrawerVelocityThreshold = 400.dp
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerOpenFraction = 0.5f
//Tag use for testing
const val DRAWER_HANDLE_TAG = "Fluent Drawer Handle"
const val DRAWER_CONTENT_TAG = "Fluent Drawer Content"
const val DRAWER_SCRIM_TAG = "Fluent Drawer Scrim"
//Drawer Handle height + padding
private val DrawerHandleHeightOffset = 20.dp
private fun Modifier.drawerHeight(
slideOver: Boolean,
fixedHeight: Float,

Просмотреть файл

@ -0,0 +1,100 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
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 com.microsoft.fluentui.util.pxToDp
object DraggableDefaults {
val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
/**
* The default velocity threshold for the drawer to snap to the nearest anchor.
*/
val VelocityThreshold = 0.5f
val PositionalThreshold = 0.5f
}
@Composable
fun convertDpToFloat(dpValue: Dp): Float {
return with(LocalDensity.current) { dpValue.toPx() }
}
val EndDrawerPadding = 56.dp
val DrawerVelocityThreshold = 400.dp
val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
const val DrawerOpenFraction = 0.5f
//Tag use for testing
const val DRAWER_HANDLE_TAG = "Fluent Drawer Handle"
const val DRAWER_CONTENT_TAG = "Fluent Drawer Content"
const val DRAWER_SCRIM_TAG = "Fluent Drawer Scrim"
//Drawer Handle height + padding
val DrawerHandleHeightOffset = 20.dp
class DrawerPositionProvider(val offset: IntOffset?) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
if (offset != null) {
return IntOffset(anchorBounds.left + offset.x, anchorBounds.top + offset.y)
}
return IntOffset(0, 0)
}
}
@Composable
fun Scrim(
open: Boolean,
onClose: () -> Unit,
fraction: () -> Float,
color: Color,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {},
) {
val dismissDrawer = if (open) {
Modifier.pointerInput(onClose) {
detectTapGestures {
if (!preventDismissalOnScrimClick) {
onClose()
}
onScrimClick() //this function runs post onClose() so that the drawer is closed before the callback is invoked
}
}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissDrawer)
.testTag(DRAWER_SCRIM_TAG)
) {
drawRect(color, alpha = fraction())
}
}

Просмотреть файл

@ -0,0 +1,483 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.view.WindowInsetsCompat
import com.microsoft.fluentui.compose.ModalPopup
import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.token.ControlTokens
import com.microsoft.fluentui.theme.token.controlTokens.BehaviorType
import com.microsoft.fluentui.theme.token.controlTokens.DrawerInfo
import com.microsoft.fluentui.theme.token.controlTokens.DrawerTokens
import com.microsoft.fluentui.tokenized.drawer.DraggableDefaults.AnimationSpec
import com.microsoft.fluentui.tokenized.drawer.DraggableDefaults.PositionalThreshold
import com.microsoft.fluentui.tokenized.drawer.DraggableDefaults.VelocityThreshold
import com.microsoft.fluentui.tokenized.drawer.DrawerState.Companion.Saver
import kotlinx.coroutines.launch
import kotlin.math.max
@OptIn(ExperimentalFoundationApi::class)
class DrawerStateV2(
private val initialValue: DrawerValue = DrawerValue.Closed,
internal val confirmValueChange: (DrawerValue) -> Boolean,
internal val expandable: Boolean = true,
internal val skipOpenState: Boolean = false
) {
internal val anchoredDraggableState: AnchoredDraggableState<DrawerValue> = AnchoredDraggableState(
initialValue,
anchors = DraggableAnchors {},
animationSpec = AnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { PositionalThreshold },
velocityThreshold = { VelocityThreshold }
)
init {
if (skipOpenState) {
require(initialValue != DrawerValue.Open) {
"The initial value must not be set to Open if skipOpenState is set to" +
" true."
}
require(expandable) {
"Invalid state: expandable = false & skipOpenState = true"
}
}
if (!expandable) {
require(initialValue != DrawerValue.Expanded) {
"The initial value must not be set to Expanded if expandable is set to" +
" false."
}
}
}
var enable: Boolean by mutableStateOf(false)
/**
* Whether drawer has Open state.
* It is false in case of skipOpenState is true.
*/
internal val hasOpenedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(DrawerValue.Open)
/**
* Whether the drawer is closed.
*/
val isClosed: Boolean
get() = anchoredDraggableState.currentValue == DrawerValue.Closed
/**
* Whether drawer has expanded state.
*/
internal val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(DrawerValue.Expanded)
var animationInProgress: Boolean = false
suspend fun open() {
enable = true
animationInProgress = true
/*
* first try to open the drawer
* if not possible then try to expand the drawer
*/
val targetValue = when {
hasOpenedState -> DrawerValue.Open
hasExpandedState -> DrawerValue.Expanded
else -> DrawerValue.Closed
}
if (targetValue != anchoredDraggableState.currentValue) {
anchoredDraggableState.animateTo(targetValue, VelocityThreshold)
animationInProgress = false
} else {
animationInProgress = false
}
}
suspend fun close() {
animationInProgress = true
try {
anchoredDraggableState.animateTo(DrawerValue.Closed, VelocityThreshold)
} catch (e: Exception) {
anchoredDraggableState.animateTo(DrawerValue.Closed, VelocityThreshold)
} finally {
animationInProgress = false
enable = false
anchoredDraggableState.updateAnchors(DraggableAnchors { })
}
}
suspend fun expand() {
enable = true
animationInProgress = true
/*
* first try to expand the drawer
* if not possible then try to open the drawer
*/
val targetValue = when {
hasExpandedState -> DrawerValue.Expanded
hasOpenedState -> DrawerValue.Open
else -> DrawerValue.Closed
}
if (targetValue != anchoredDraggableState.currentValue) {
anchoredDraggableState.animateTo(targetValue = targetValue, VelocityThreshold)
animationInProgress = false
} else {
animationInProgress = false
}
}
companion object {
/**
* The default [Saver] implementation for [DrawerStateV2].
*/
fun Saver(
expandable: Boolean,
skipOpenState: Boolean,
confirmValueChange: (DrawerValue) -> Boolean
) =
Saver<DrawerStateV2, DrawerValue>(
save = { it.anchoredDraggableState.currentValue },
restore = {
DrawerStateV2(
initialValue = it,
expandable = expandable,
skipOpenState = skipOpenState,
confirmValueChange = confirmValueChange
)
}
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberDrawerStateV2(confirmValueChange: (DrawerValue) -> Boolean = { true }): DrawerStateV2 {
return rememberSaveable(
saver = DrawerStateV2.Saver(
expandable = true,
skipOpenState = false,
confirmValueChange = confirmValueChange
)
) {
DrawerStateV2(
initialValue = DrawerValue.Closed,
expandable = true,
skipOpenState = false,
confirmValueChange = confirmValueChange
)
}
}
@Composable
fun rememberBottomDrawerStateV2(
expandable: Boolean = true,
skipOpenState: Boolean = false,
confirmValueChange: (DrawerValue) -> Boolean = { true }
): DrawerStateV2 {
return rememberSaveable(
confirmValueChange, expandable, skipOpenState,
saver = DrawerStateV2.Saver(expandable, skipOpenState, confirmValueChange)
) {
DrawerStateV2(DrawerValue.Closed, confirmValueChange, expandable, skipOpenState)
}
}
@OptIn(ExperimentalFoundationApi::class)
fun Modifier.bottomDrawerAnchoredDraggable(
drawerState: DrawerStateV2,
slideOver: Boolean,
maxOpenHeight: Float,
fullHeight: Float,
drawerHeight: Float?
): Modifier {
val modifier = if (slideOver) {
if (drawerHeight != null) {
val minHeight = 0f
val bottomOpenStateY = max(maxOpenHeight, fullHeight - drawerHeight)
val bottomExpandedStateY = max(minHeight, fullHeight - drawerHeight)
val drawerStateAnchors = drawerState.anchoredDraggableState.anchors
val anchors: DraggableAnchors<DrawerValue> =
if (drawerHeight <= maxOpenHeight) { // when contentHeight is less than maxOpenHeight
if (drawerStateAnchors.hasAnchorFor(DrawerValue.Expanded)) {
/*
*For dynamic content when drawerHeight was previously greater than maxOpenHeight and now less than maxOpenHEight
*The old anchors won't have Open state, so we need to continue with Expanded state.
*/
DraggableAnchors {
DrawerValue.Expanded at bottomOpenStateY
DrawerValue.Closed at fullHeight
}
} else {
DraggableAnchors {
DrawerValue.Open at bottomOpenStateY
DrawerValue.Closed at fullHeight
}
}
} else {
if (drawerState.expandable) {
if (drawerState.skipOpenState) {
if (drawerStateAnchors.hasAnchorFor(DrawerValue.Open)) {
/*
*For dynamic content when drawerHeight was previously less than maxOpenHeight and now greater than maxOpenHEight
*The old anchors won't have Expanded state, so we need to continue with Open state.
*/
DraggableAnchors {
DrawerValue.Open at bottomExpandedStateY // when drawerHeight is greater than maxOpenHeight but less than fullHeight, then Expanded state starts from fullHeight-drawerHeight
DrawerValue.Closed at fullHeight
}
} else {
DraggableAnchors {
DrawerValue.Expanded at bottomExpandedStateY // when drawerHeight is greater than maxOpenHeight but less than fullHeight, then Expanded state starts from fullHeight-drawerHeight
DrawerValue.Closed at fullHeight
}
}
} else {
DraggableAnchors {
DrawerValue.Open at maxOpenHeight
DrawerValue.Expanded at bottomExpandedStateY
DrawerValue.Closed at fullHeight
}
}
} else {
DraggableAnchors {
DrawerValue.Open at maxOpenHeight
DrawerValue.Closed at fullHeight
}
}
}
drawerState.anchoredDraggableState.updateAnchors(anchors)
Modifier.anchoredDraggable(
state = drawerState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = false
)
} else {
Modifier
}
} else {
val anchors: DraggableAnchors<DrawerValue> = if (drawerState.expandable) {
if (drawerState.skipOpenState) {
DraggableAnchors {
DrawerValue.Expanded at 0f
DrawerValue.Closed at fullHeight
}
} else {
DraggableAnchors {
DrawerValue.Open at maxOpenHeight
DrawerValue.Expanded at 0F
DrawerValue.Closed at fullHeight
}
}
} else {
DraggableAnchors {
DrawerValue.Open at maxOpenHeight
DrawerValue.Closed at fullHeight
}
}
drawerState.anchoredDraggableState.updateAnchors(anchors)
Modifier.anchoredDraggable(
state = drawerState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = false
)
}
return this.then(modifier)
}
@Composable
fun DrawerV2(
modifier: Modifier = Modifier,
behaviorType: BehaviorType = BehaviorType.BOTTOM,
drawerState: DrawerStateV2 = rememberDrawerStateV2(),
scrimVisible: Boolean = true,
offset: IntOffset? = null,
drawerTokens: DrawerTokens? = null,
drawerContent: @Composable () -> Unit,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {}
) {
if (drawerState.enable) {
val themeID =
FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise.
val tokens = drawerTokens
?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.DrawerControlType] as DrawerTokens
val popupPositionProvider = DrawerPositionProvider(offset)
val scope = rememberCoroutineScope()
val close: () -> Unit = {
if (drawerState.confirmValueChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
}
val drawerInfo = DrawerInfo(type = behaviorType)
Popup(
onDismissRequest = close,
popupPositionProvider = popupPositionProvider,
properties = PopupProperties(focusable = true, clippingEnabled = (offset == null))
)
{
val drawerShape: Shape =
when (behaviorType) {
BehaviorType.BOTTOM, BehaviorType.BOTTOM_SLIDE_OVER -> RoundedCornerShape(
topStart = tokens.borderRadius(drawerInfo),
topEnd = tokens.borderRadius(drawerInfo)
)
BehaviorType.TOP -> RoundedCornerShape(
bottomStart = tokens.borderRadius(drawerInfo),
bottomEnd = tokens.borderRadius(drawerInfo)
)
else -> RoundedCornerShape(tokens.borderRadius(drawerInfo))
}
val drawerElevation: Dp = tokens.elevation(drawerInfo)
val drawerBackgroundColor: Brush =
tokens.backgroundBrush(drawerInfo)
val drawerHandleColor: Color = tokens.handleColor(drawerInfo)
val scrimOpacity: Float = tokens.scrimOpacity(drawerInfo)
val scrimColor: Color =
tokens.scrimColor(drawerInfo).copy(alpha = scrimOpacity)
when (behaviorType) {
BehaviorType.BOTTOM, BehaviorType.BOTTOM_SLIDE_OVER -> BottomDrawerV2(
modifier = modifier,
drawerState = drawerState,
drawerShape = drawerShape,
drawerElevation = drawerElevation,
drawerBackground = drawerBackgroundColor,
drawerHandleColor = drawerHandleColor,
scrimColor = scrimColor,
scrimVisible = scrimVisible,
slideOver = behaviorType == BehaviorType.BOTTOM_SLIDE_OVER,
showHandle = true,
onDismiss = close,
drawerContent = drawerContent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
onScrimClick = onScrimClick
)
BehaviorType.TOP -> TopDrawerV2(
modifier = modifier,
drawerState = drawerState,
drawerShape = drawerShape,
drawerElevation = drawerElevation,
drawerBackground = drawerBackgroundColor,
drawerHandleColor = drawerHandleColor,
scrimColor = scrimColor,
scrimVisible = scrimVisible,
onDismiss = close,
drawerContent = drawerContent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
onScrimClick = onScrimClick
)
BehaviorType.LEFT_SLIDE_OVER, BehaviorType.RIGHT_SLIDE_OVER -> HorizontalDrawerV2(
behaviorType = behaviorType,
modifier = modifier,
drawerState = drawerState,
drawerShape = drawerShape,
drawerElevation = drawerElevation,
drawerBackground = drawerBackgroundColor,
scrimColor = scrimColor,
scrimVisible = scrimVisible,
onDismiss = close,
drawerContent = drawerContent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
onScrimClick = onScrimClick
)
}
}
}
}
@Composable
fun BottomDrawerV2(
modifier: Modifier = Modifier,
drawerState: DrawerStateV2 = rememberDrawerStateV2(),
slideOver: Boolean = true,
scrimVisible: Boolean = true,
showHandle: Boolean = true,
enableSwipeDismiss: Boolean = true,
windowInsetsType: Int = WindowInsetsCompat.Type.systemBars(),
drawerTokens: DrawerTokens? = null,
drawerContent: @Composable () -> Unit,
maxLandscapeWidthFraction: Float = 1F,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {},
) {
if (drawerState.enable) {
val themeID =
FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise.
val tokens = drawerTokens
?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.DrawerControlType] as DrawerTokens
val scope = rememberCoroutineScope()
val close: () -> Unit = {
if (drawerState.confirmValueChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
}
val behaviorType =
if (slideOver) BehaviorType.BOTTOM_SLIDE_OVER else BehaviorType.BOTTOM
val drawerInfo = DrawerInfo(type = behaviorType)
ModalPopup(
onDismissRequest = close,
windowInsetsType = windowInsetsType
)
{
val drawerShape: Shape =
RoundedCornerShape(
topStart = tokens.borderRadius(drawerInfo),
topEnd = tokens.borderRadius(drawerInfo)
)
val drawerElevation: Dp = tokens.elevation(drawerInfo)
val drawerBackgroundColor: Brush =
tokens.backgroundBrush(drawerInfo)
val drawerHandleColor: Color = tokens.handleColor(drawerInfo)
val scrimOpacity: Float = tokens.scrimOpacity(drawerInfo)
val scrimColor: Color =
tokens.scrimColor(drawerInfo).copy(alpha = scrimOpacity)
BottomDrawerV2(
modifier = modifier,
drawerState = drawerState,
drawerShape = drawerShape,
drawerElevation = drawerElevation,
drawerBackground = drawerBackgroundColor,
drawerHandleColor = drawerHandleColor,
scrimColor = scrimColor,
scrimVisible = scrimVisible,
slideOver = slideOver,
showHandle = showHandle,
enableSwipeDismiss = enableSwipeDismiss,
onDismiss = close,
drawerContent = drawerContent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
maxLandscapeWidthFraction = maxLandscapeWidthFraction,
onScrimClick = onScrimClick
)
}
}
}

Просмотреть файл

@ -0,0 +1,170 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
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.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.microsoft.fluentui.theme.token.controlTokens.BehaviorType
import com.microsoft.fluentui.tokenized.calculateFraction
import com.microsoft.fluentui.util.dpToPx
import com.microsoft.fluentui.util.pxToDp
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalDrawerV2(
modifier: Modifier,
behaviorType: BehaviorType,
drawerState: DrawerStateV2,
drawerShape: Shape,
drawerElevation: Dp,
drawerBackground: Brush,
scrimColor: Color,
scrimVisible: Boolean,
onDismiss: () -> Unit,
drawerContent: @Composable () -> Unit,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {}
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val modalDrawerConstraints = constraints
// TODO : think about Infinite max bounds case
if (!modalDrawerConstraints.hasBoundedWidth) {
throw IllegalStateException("Drawer shouldn't have infinite width")
}
val fullWidth = modalDrawerConstraints.maxWidth.toFloat()
var drawerWidth by remember(fullWidth) { mutableStateOf(fullWidth) }
//Hack to get exact drawerHeight wrt to content.
val visible = remember { mutableStateOf(true) }
if (visible.value) {
Box(
modifier = Modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
drawerWidth = placeable.width.toFloat()
visible.value = false
}
}
) {
drawerContent()
}
} else {
val paddingPx = pxToDp(max(dpToPx(EndDrawerPadding), (fullWidth - drawerWidth)))
val leftSlide = behaviorType == BehaviorType.LEFT_SLIDE_OVER
val minValue =
modalDrawerConstraints.maxWidth.toFloat() * (if (leftSlide) (-1F) else (1F))
val maxValue = 0f
val anchors = DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
}
drawerState.anchoredDraggableState.updateAnchors(anchors)
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val offset = drawerState.anchoredDraggableState.offset
Scrim(
open = !drawerState.isClosed,
onClose = onDismiss,
fraction = {
calculateFraction(minValue, maxValue, offset)
},
color = if (scrimVisible) scrimColor else Color.Transparent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
onScrimClick = onScrimClick
)
Box(
modifier = with(LocalDensity.current) {
Modifier
.sizeIn(
minWidth = modalDrawerConstraints.minWidth.toDp(),
minHeight = modalDrawerConstraints.minHeight.toDp(),
maxWidth = modalDrawerConstraints.maxWidth.toDp(),
maxHeight = modalDrawerConstraints.maxHeight.toDp()
)
}
.offset { IntOffset(offset.roundToInt(), 0) }
.padding(
start = if (leftSlide) 0.dp else paddingPx,
end = if (leftSlide) paddingPx else 0.dp
)
.semantics {
if (!drawerState.isClosed) {
dismiss {
onDismiss()
true
}
}
}
.shadow(drawerElevation)
.clip(drawerShape)
.background(drawerBackground)
.anchoredDraggable(
state = drawerState.anchoredDraggableState,
// thresholds = { _, _ -> FixedThreshold(pxToDp(value = drawerWidth / 2)) },
orientation = Orientation.Horizontal,
enabled = false,
reverseDirection = isRtl,
// velocityThreshold = DrawerVelocityThreshold,
),
) {
Column(
Modifier
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
drawerState.anchoredDraggableState.dispatchRawDelta(delta)
},
onDragStopped = { velocity ->
launch {
drawerState.anchoredDraggableState.settle(
velocity
)
if (drawerState.isClosed) {
onDismiss()
}
}
},
)
.testTag(DRAWER_CONTENT_TAG), content = { drawerContent() })
}
}
}
}

Просмотреть файл

@ -0,0 +1,189 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.microsoft.fluentui.drawer.R
import com.microsoft.fluentui.theme.token.Icon
import com.microsoft.fluentui.tokenized.calculateFraction
import com.microsoft.fluentui.util.dpToPx
import com.microsoft.fluentui.util.pxToDp
import kotlinx.coroutines.launch
import kotlin.math.min
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TopDrawerV2(
modifier: Modifier,
drawerState: DrawerStateV2,
drawerShape: Shape,
drawerElevation: Dp,
drawerBackground: Brush,
drawerHandleColor: Color,
scrimColor: Color,
scrimVisible: Boolean,
onDismiss: () -> Unit,
drawerContent: @Composable () -> Unit,
preventDismissalOnScrimClick: Boolean = false,
onScrimClick: () -> Unit = {}
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight.toFloat()
var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) }
Box(
modifier = Modifier
.alpha(0f)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
drawerHeight =
placeable.height.toFloat() + dpToPx(DrawerHandleHeightOffset)
}
}
) {
drawerContent()
}
val maxOpenHeight = fullHeight * DrawerOpenFraction
val minHeight = 0f
val topCloseHeight = minHeight
val topOpenHeight = min(maxOpenHeight, drawerHeight)
val minValue: Float = topCloseHeight
val maxValue: Float = topOpenHeight
val anchors = DraggableAnchors<DrawerValue> {
DrawerValue.Closed at topCloseHeight
DrawerValue.Open at topOpenHeight
}
drawerState.anchoredDraggableState.updateAnchors(anchors)
val drawerConstraints = with(LocalDensity.current) {
Modifier
.sizeIn(
maxWidth = constraints.maxWidth.toDp(),
maxHeight = constraints.maxHeight.toDp()
)
}
val drawerStateOffset = drawerState.anchoredDraggableState.offset
Scrim(
open = !drawerState.isClosed,
onClose = onDismiss,
fraction = {
calculateFraction(minValue, maxValue, drawerStateOffset)
},
color = if (scrimVisible) scrimColor else Color.Transparent,
preventDismissalOnScrimClick = preventDismissalOnScrimClick,
onScrimClick = onScrimClick
)
Box(
drawerConstraints
.offset { IntOffset(0, 0) }
.semantics {
if (!drawerState.isClosed) {
dismiss {
onDismiss()
true
}
}
}
.height(
pxToDp(drawerStateOffset)
)
.shadow(drawerElevation)
.clip(drawerShape)
.background(drawerBackground)
.anchoredDraggable(
state = drawerState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = false
)
.focusable(false),
) {
ConstraintLayout(modifier = Modifier.padding(bottom = 8.dp)) {
val (drawerContentConstrain, drawerHandleConstrain) = createRefs()
Column(modifier = Modifier
.offset { IntOffset(0, 0) }
.padding(bottom = 8.dp)
.constrainAs(drawerContentConstrain) {
top.linkTo(parent.top)
bottom.linkTo(drawerHandleConstrain.top)
}
.focusTarget()
.testTag(DRAWER_CONTENT_TAG), content = { drawerContent() }
)
Column(horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.constrainAs(drawerHandleConstrain) {
top.linkTo(drawerContentConstrain.bottom)
bottom.linkTo(parent.bottom)
}
.fillMaxWidth()
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
drawerState.anchoredDraggableState.dispatchRawDelta(delta)
},
onDragStopped = { velocity ->
launch {
drawerState.anchoredDraggableState.settle(
velocity
)
if (drawerState.isClosed) {
onDismiss()
}
}
},
)
.testTag(DRAWER_HANDLE_TAG)
) {
Icon(
painterResource(id = R.drawable.ic_drawer_handle),
contentDescription = null,
tint = drawerHandleColor
)
}
}
}
}
}