Merge pull request #691 from microsoft/joypal/anchoredDraggable

Migrate from Swipeable to AnchoredDraggable and other refactor for Drawer and Bottom Drawer
This commit is contained in:
Joyeeta Pal 2024-09-25 11:48:07 +05:30 коммит произвёл GitHub
Родитель 47d7d73fe1 0cd7ee9310
Коммит bff6281c5e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
8 изменённых файлов: 1837 добавлений и 803 удалений

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

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

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

@ -68,6 +68,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "com.microsoft.device:dualscreen-layout:$duoVersion"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"
implementation "androidx.compose.foundation:foundation:$composeFoundationVersion"
implementation "androidx.compose.material:material"
testImplementation "junit:junit:$junitVersion"

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

@ -0,0 +1,910 @@
/*
* Copyright 2022 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 com.microsoft.fluentui.compose
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.gestures.DragScope
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import kotlin.math.abs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* Structure that represents the anchors of a [AnchoredDraggableState].
*
* See the DraggableAnchors factory method to construct drag anchors using a default implementation.
*/
interface DraggableAnchors<T> {
/**
* Get the anchor position for an associated [value]
*
* @param value The value to look up
*
* @return The position of the anchor, or [Float.NaN] if the anchor does not exist
*/
fun positionOf(value: T): Float
/**
* Whether there is an anchor position associated with the [value]
*
* @param value The value to look up
*
* @return true if there is an anchor for this value, false if there is no anchor for this value
*/
fun hasAnchorFor(value: T): Boolean
/**
* Find the closest anchor to the [position].
*
* @param position The position to start searching from
*
* @return The closest anchor or null if the anchors are empty
*/
fun closestAnchor(position: Float): T?
/**
* Find the closest anchor to the [position], in the specified direction.
*
* @param position The position to start searching from
* @param searchUpwards Whether to search upwards from the current position or downwards
*
* @return The closest anchor or null if the anchors are empty
*/
fun closestAnchor(position: Float, searchUpwards: Boolean): T?
/**
* The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty.
*/
fun minAnchor(): Float
/**
* The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty.
*/
fun maxAnchor(): Float
/**
* The amount of anchors
*/
val size: Int
}
/**
* [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and
* corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable
* [DraggableAnchors] instance later on.
*/
class DraggableAnchorsConfig<T> {
internal val anchors = mutableMapOf<T, Float>()
/**
* Set the anchor position for [this] anchor.
*
* @param position The anchor position.
*/
@Suppress("BuilderSetStyle")
infix fun T.at(position: Float) {
anchors[this] = position
}
}
/**
* Create a new [DraggableAnchors] instance using a builder function.
*
* @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors
* @return A new [DraggableAnchors] instance with the anchor positions set by the `builder`
* function.
*/
fun <T : Any> DraggableAnchors(
builder: DraggableAnchorsConfig<T>.() -> Unit
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
/**
* Enable drag gestures between a set of predefined values.
*
* When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag
* delta. You should use this offset to move your content accordingly (see [Modifier.offset]).
* When the drag ends, the offset will be animated to one of the anchors and when that anchor is
* reached, the value of the [AnchoredDraggableState] will also be updated to the value
* corresponding to the new anchor.
*
* Dragging is constrained between the minimum and maximum anchors.
*
* @param state The associated [AnchoredDraggableState].
* @param orientation The orientation in which the [anchoredDraggable] can be dragged.
* @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input.
* @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom
* drag will behave like bottom to top, and a left to right drag will behave like right to left.
* @param interactionSource Optional [MutableInteractionSource] that will passed on to
* the internal [Modifier.draggable].
*/
fun <T> Modifier.anchoredDraggable(
state: AnchoredDraggableState<T>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null
) = draggable(
state = state.draggableState,
orientation = orientation,
enabled = enabled,
interactionSource = interactionSource,
reverseDirection = reverseDirection,
startDragImmediately = state.isAnimationRunning,
onDragStopped = { velocity -> launch { state.settle(velocity) } }
)
/**
* Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to
* a new value.
*
* @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the
* access to this scope.
*/
interface AnchoredDragScope {
/**
* Assign a new value for an offset value for [AnchoredDraggableState].
*
* @param newOffset new value for [AnchoredDraggableState.offset].
* @param lastKnownVelocity last known velocity (if known)
*/
fun dragTo(
newOffset: Float,
lastKnownVelocity: Float = 0f
)
}
/**
* State of the [anchoredDraggable] modifier.
* Use the constructor overload with anchors if the anchors are defined in composition, or update
* the anchors using [updateAnchors].
*
* This contains necessary information about any ongoing drag or animation and provides methods
* to change the state either immediately or by starting an animation.
*
* @param initialValue The initial value of the state.
* @param positionalThreshold The positional threshold, in px, to be used when calculating the
* target state while a drag is in progress and when settling after the drag ends. This is the
* distance from the start of a transition. It will be, depending on the direction of the
* interaction, added or subtracted from/to the origin offset. It should always be a positive value.
* @param velocityThreshold The velocity threshold (in px per second) that the end velocity has to
* exceed in order to animate to the next state, even if the [positionalThreshold] has not been
* reached.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Stable
class AnchoredDraggableState<T>(
initialValue: T,
internal val positionalThreshold: (totalDistance: Float) -> Float,
internal val velocityThreshold: () -> Float,
val animationSpec: AnimationSpec<Float>,
internal val confirmValueChange: (newValue: T) -> Boolean = { true }
) {
/**
* Construct an [AnchoredDraggableState] instance with anchors.
*
* @param initialValue The initial value of the state.
* @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state
* change.
* @param positionalThreshold The positional threshold, in px, to be used when calculating the
* target state while a drag is in progress and when settling after the drag ends. This is the
* distance from the start of a transition. It will be, depending on the direction of the
* interaction, added or subtracted from/to the origin offset. It should always be a positive
* value.
* @param velocityThreshold The velocity threshold (in px per second) that the end velocity has
* to exceed in order to animate to the next state, even if the [positionalThreshold] has not
* been reached.
*/
constructor(
initialValue: T,
anchors: DraggableAnchors<T>,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
animationSpec: AnimationSpec<Float>,
confirmValueChange: (newValue: T) -> Boolean = { true }
) : this(
initialValue,
positionalThreshold,
velocityThreshold,
animationSpec,
confirmValueChange
) {
this.anchors = anchors
trySnapTo(initialValue)
}
private val dragMutex = MutatorMutex()
internal var minBound = Float.NEGATIVE_INFINITY
internal val draggableState = object : DraggableState {
private val dragScope = object : DragScope {
override fun dragBy(pixels: Float) {
with(anchoredDragScope) {
dragTo(newOffsetForDelta(pixels))
}
}
}
override suspend fun drag(
dragPriority: MutatePriority,
block: suspend DragScope.() -> Unit
) {
this@AnchoredDraggableState.anchoredDrag(dragPriority) {
with(dragScope) { block() }
}
}
override fun dispatchRawDelta(delta: Float) {
this@AnchoredDraggableState.dispatchRawDelta(delta)
}
}
/**
* The current value of the [AnchoredDraggableState].
*/
var currentValue: T by mutableStateOf(initialValue)
private set
/**
* The target value. This is the closest value to the current offset, taking into account
* positional thresholds. If no interactions like animations or drags are in progress, this
* will be the current value.
*/
val targetValue: T by derivedStateOf {
dragTarget ?: run {
val currentOffset = offset
if (!currentOffset.isNaN()) {
computeTarget(currentOffset, currentValue, velocity = 0f)
} else currentValue
}
}
/**
* The closest value in the swipe direction from the current offset, not considering thresholds.
* If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if
* specified).
*/
internal val closestValue: T by derivedStateOf {
dragTarget ?: run {
val currentOffset = offset
if (!currentOffset.isNaN()) {
computeTargetWithoutThresholds(currentOffset, currentValue)
} else currentValue
}
}
/**
* The current offset, or [Float.NaN] if it has not been initialized yet.
*
* The offset will be initialized when the anchors are first set through [updateAnchors].
*
* Strongly consider using [requireOffset] which will throw if the offset is read before it is
* initialized. This helps catch issues early in your workflow.
*/
var offset: Float by mutableFloatStateOf(Float.NaN)
private set
/**
* Require the current offset.
*
* @see offset
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float {
check(!offset.isNaN()) {
"The offset was read before being initialized. Did you access the offset in a phase " +
"before layout, like effects or composition?"
}
return offset
}
/*
It's a flag to indicate whether anchors are filled or not.
Useful as a flag to let expand(), open() to get to know whether anchors are filled or not
when launched for the very first time
*/
var anchorsFilled: Boolean by mutableStateOf(false)
/**
* Whether an animation is currently in progress.
*/
val isAnimationRunning: Boolean get() = dragTarget != null
/**
* The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f]
* bounds, or 1f if the [AnchoredDraggableState] is in a settled state.
*/
/*@FloatRange(from = 0f, to = 1f)*/
val progress: Float by derivedStateOf(structuralEqualityPolicy()) {
val a = anchors.positionOf(currentValue)
val b = anchors.positionOf(closestValue)
val distance = abs(b - a)
if (!distance.isNaN() && distance > 1e-6f) {
val progress = (this.requireOffset() - a) / (b - a)
// If we are very close to 0f or 1f, we round to the closest
if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress
} else 1f
}
/**
* The velocity of the last known animation. Gets reset to 0f when an animation completes
* successfully, but does not get reset when an animation gets interrupted.
* You can use this value to provide smooth reconciliation behavior when re-targeting an
* animation.
*/
var lastVelocity: Float by mutableFloatStateOf(0f)
private set
private var dragTarget: T? by mutableStateOf(null)
var anchors: DraggableAnchors<T> by mutableStateOf(emptyDraggableAnchors())
private set
/**
* Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget],
* otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new
* anchors.
*
* <b>If your anchors depend on the size of the layout, updateAnchors should be called in the
* layout (placement) phase, e.g. through Modifier.onSizeChanged.</b> This ensures that the
* state is set up within the same frame.
* For static anchors, or anchors with different data dependencies, [updateAnchors] is safe to
* be called from side effects or layout.
*
* @param newAnchors The new anchors.
* @param newTarget The new target, by default the closest anchor or the current target if there
* are no anchors.
*/
fun updateAnchors(
newAnchors: DraggableAnchors<T>,
newTarget: T = if (!offset.isNaN()) {
newAnchors.closestAnchor(offset) ?: targetValue
} else targetValue
) {
if (anchors != newAnchors) {
anchors = newAnchors
// Attempt to snap. If nobody is holding the lock, we can immediately update the offset.
// If anybody is holding the lock, we send a signal to restart the ongoing work with the
// updated anchors.
val snapSuccessful = trySnapTo(newTarget)
if (!snapSuccessful) {
dragTarget = newTarget
}
}
anchorsFilled = true
}
/**
* Find the closest anchor, taking into account the [velocityThreshold] and
* [positionalThreshold], and settle at it with an animation.
*
* If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and
* [positionalThreshold] will be the target. If the [velocity] is higher than the
* [velocityThreshold], the [positionalThreshold] will <b>not</b> be considered and the next
* anchor in the direction indicated by the sign of the [velocity] will be the target.
*/
suspend fun settle(velocity: Float) {
val previousValue = this.currentValue
val targetValue = computeTarget(
offset = requireOffset(),
currentValue = previousValue,
velocity = velocity
)
if (confirmValueChange(targetValue)) {
animateTo(targetValue, velocity)
} else {
// If the user vetoed the state change, rollback to the previous state.
animateTo(previousValue, velocity)
}
}
private fun computeTarget(
offset: Float,
currentValue: T,
velocity: Float
): T {
val currentAnchors = anchors
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
val velocityThresholdPx = velocityThreshold()
return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) {
currentValue
} else if (currentAnchorPosition < offset) {
// Swiping from lower to upper (positive).
if (velocity >= velocityThresholdPx) {
currentAnchors.closestAnchor(offset, true)!!
} else {
val upper = currentAnchors.closestAnchor(offset, true)!!
val distance = abs(currentAnchors.positionOf(upper) - currentAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val absoluteThreshold = abs(currentAnchorPosition + relativeThreshold)
if (offset < absoluteThreshold) currentValue else upper
}
} else {
// Swiping from upper to lower (negative).
if (velocity <= -velocityThresholdPx) {
currentAnchors.closestAnchor(offset, false)!!
} else {
val lower = currentAnchors.closestAnchor(offset, false)!!
val distance = abs(currentAnchorPosition - currentAnchors.positionOf(lower))
val relativeThreshold = abs(positionalThreshold(distance))
val absoluteThreshold = abs(currentAnchorPosition - relativeThreshold)
if (offset < 0) {
// For negative offsets, larger absolute thresholds are closer to lower anchors
// than smaller ones.
if (abs(offset) < absoluteThreshold) currentValue else lower
} else {
if (offset > absoluteThreshold) currentValue else lower
}
}
}
}
private fun computeTargetWithoutThresholds(
offset: Float,
currentValue: T,
): T {
val currentAnchors = anchors
val currentAnchor = currentAnchors.positionOf(currentValue)
return if (currentAnchor == offset || currentAnchor.isNaN()) {
currentValue
} else if (currentAnchor < offset) {
currentAnchors.closestAnchor(offset, true) ?: currentValue
} else {
currentAnchors.closestAnchor(offset, false) ?: currentValue
}
}
private val anchoredDragScope: AnchoredDragScope = object : AnchoredDragScope {
override fun dragTo(newOffset: Float, lastKnownVelocity: Float) {
offset = newOffset
lastVelocity = lastKnownVelocity
}
}
/**
* Call this function to take control of drag logic and perform anchored drag with the latest
* anchors.
*
* All actions that change the [offset] of this [AnchoredDraggableState] must be performed
* within an [anchoredDrag] block (even if they don't call any other methods on this object)
* in order to guarantee that mutual exclusion is enforced.
*
* If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing
* drag, the ongoing drag will be cancelled.
*
* <b>If the [anchors] change while the [block] is being executed, it will be cancelled and
* re-executed with the latest anchors and target.</b> This allows you to target the correct
* state.
*
* @param dragPriority of the drag operation
* @param block perform anchored drag given the current anchor provided
*/
suspend fun anchoredDrag(
dragPriority: MutatePriority = MutatePriority.Default,
block: suspend AnchoredDragScope.(anchors: DraggableAnchors<T>) -> Unit
) {
try {
dragMutex.mutate(dragPriority) {
restartable(inputs = { anchors }) { latestAnchors ->
anchoredDragScope.block(latestAnchors)
}
}
} finally {
val closest = anchors.closestAnchor(offset)
if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) {
currentValue = closest
}
}
}
/**
* Call this function to take control of drag logic and perform anchored drag with the latest
* anchors and target.
*
* All actions that change the [offset] of this [AnchoredDraggableState] must be performed
* within an [anchoredDrag] block (even if they don't call any other methods on this object)
* in order to guarantee that mutual exclusion is enforced.
*
* This overload allows the caller to hint the target value that this [anchoredDrag] is intended
* to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so
* consumers can reflect it in their UIs.
*
* <b>If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being
* executed, it will be cancelled and re-executed with the latest anchors and target.</b> This
* allows you to target the correct state.
*
* If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing
* drag, the ongoing drag will be cancelled.
*
* @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to
* @param dragPriority of the drag operation
* @param block perform anchored drag given the current anchor provided
*/
suspend fun anchoredDrag(
targetValue: T,
dragPriority: MutatePriority = MutatePriority.Default,
block: suspend AnchoredDragScope.(anchors: DraggableAnchors<T>, targetValue: T) -> Unit
) {
if (anchors.hasAnchorFor(targetValue)) {
try {
dragMutex.mutate(dragPriority) {
dragTarget = targetValue
restartable(
inputs = { anchors to this@AnchoredDraggableState.targetValue }
) { (latestAnchors, latestTarget) ->
anchoredDragScope.block(latestAnchors, latestTarget)
}
}
} finally {
dragTarget = null
val closest = anchors.closestAnchor(offset)
if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) {
currentValue = closest
}
}
} else {
// Todo: b/283467401, revisit this behavior
currentValue = targetValue
}
}
internal fun newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
/**
* Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState].
*
* @return The delta the consumed by the [AnchoredDraggableState]
*/
fun dispatchRawDelta(delta: Float): Float {
val newOffset = newOffsetForDelta(delta)
val oldOffset = if (offset.isNaN()) 0f else offset
offset = newOffset
return newOffset - oldOffset
}
/**
* Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag
* transaction like a drag or an animation is progress. If there is another interaction in
* progress, the suspending [snapTo] overload needs to be used.
*
* @return true if the synchronous snap was successful, or false if we couldn't snap synchronous
*/
private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate {
with(anchoredDragScope) {
val targetOffset = anchors.positionOf(targetValue)
if (!targetOffset.isNaN()) {
dragTo(targetOffset)
dragTarget = null
}
currentValue = targetValue
}
}
companion object {
/**
* The default [Saver] implementation for [AnchoredDraggableState].
*/
fun <T : Any> Saver(
animationSpec: AnimationSpec<Float>,
positionalThreshold: (distance: Float) -> Float,
velocityThreshold: () -> Float,
confirmValueChange: (T) -> Boolean = { true },
) = Saver<AnchoredDraggableState<T>, T>(
save = { it.currentValue },
restore = {
AnchoredDraggableState(
initialValue = it,
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold
)
}
)
}
}
/**
* Snap to a [targetValue] without any animation.
* If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will
* be updated to the [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
suspend fun <T> AnchoredDraggableState<T>.snapTo(targetValue: T) {
anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
val targetOffset = anchors.positionOf(latestTarget)
if (!targetOffset.isNaN()) dragTo(targetOffset)
}
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [AnchoredDraggableState.currentValue] will
* be updated to the [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
* @param velocity The velocity the animation should start with
*/
suspend fun <T> AnchoredDraggableState<T>.animateTo(
targetValue: T,
velocity: Float = this.lastVelocity,
) {
anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
val targetOffset = anchors.positionOf(latestTarget)
if (!targetOffset.isNaN()) {
var prev = if (offset.isNaN()) 0f else offset
animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
// Our onDrag coerces the value within the bounds, but an animation may
// overshoot, for example a spring animation or an overshooting interpolator
// We respect the user's intention and allow the overshoot, but still use
// DraggableState's drag for its mutex.
dragTo(value, velocity)
prev = value
}
}
}
}
private class AnchoredDragFinishedSignal : CancellationException() {
override fun fillInStackTrace(): Throwable {
stackTrace = emptyArray()
return this
}
}
private suspend fun <I> restartable(inputs: () -> I, block: suspend (I) -> Unit) {
try {
coroutineScope {
var previousDrag: Job? = null
snapshotFlow(inputs)
.collect { latestInputs ->
previousDrag?.apply {
cancel(AnchoredDragFinishedSignal())
join()
}
previousDrag = launch(start = CoroutineStart.UNDISPATCHED) {
block(latestInputs)
this@coroutineScope.cancel(AnchoredDragFinishedSignal())
}
}
}
} catch (anchoredDragFinished: AnchoredDragFinishedSignal) {
// Ignored
}
}
val <T> AnchoredDraggableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && offset > minBound) {
settle(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
settle(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
val <T> AnchoredDraggableState<T>.PostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag && available.toFloat() > 0) {
dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
settle(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
val <T> AnchoredDraggableState<T>.NonDismissiblePostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag && available.toFloat() < 0) {
dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
settle(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
val <T> AnchoredDraggableState<T>.NonDismissiblePreUpPostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag && available.toFloat() < 0) {
dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && offset > minBound) {
settle(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
private fun <T> emptyDraggableAnchors() = MapDraggableAnchors<T>(emptyMap())
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN
override fun hasAnchorFor(value: T) = anchors.containsKey(value)
override fun closestAnchor(position: Float): T? = anchors.minByOrNull {
abs(position - it.value)
}?.key
override fun closestAnchor(
position: Float,
searchUpwards: Boolean
): T? {
return anchors.minByOrNull { (_, anchor) ->
val delta = if (searchUpwards) anchor - position else position - anchor
if (delta < 0) Float.POSITIVE_INFINITY else delta
}?.key
}
override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN
override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN
override val size: Int
get() = anchors.size
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MapDraggableAnchors<*>) return false
return anchors == other.anchors
}
override fun hashCode() = 31 * anchors.hashCode()
override fun toString() = "MapDraggableAnchors($anchors)"
}

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

@ -0,0 +1,433 @@
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.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.input.nestedscroll.nestedScroll
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.compose.DraggableAnchors
import com.microsoft.fluentui.compose.NonDismissiblePreUpPostDownNestedScrollConnection
import com.microsoft.fluentui.compose.PostDownNestedScrollConnection
import com.microsoft.fluentui.compose.anchoredDraggable
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.max
import kotlin.math.min
import kotlin.math.roundToInt
private fun Modifier.drawerHeight(
slideOver: Boolean,
fixedHeight: Float,
fullHeight: Float,
drawerState: DrawerState
): 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)
}
@Composable
fun BottomDrawer(
modifier: Modifier,
drawerState: DrawerState,
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.let {
if (drawerState.anchoredDraggableState.anchors.hasAnchorFor(DrawerValue.Expanded)) {
DrawerValue.Expanded
} else if (drawerState.anchoredDraggableState.anchors.hasAnchorFor(DrawerValue.Open)) {
DrawerValue.Open
} else {
DrawerValue.Closed
}
}
} 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.anchoredDraggableState.NonDismissiblePreUpPostDownNestedScrollConnection else
if (slideOver) drawerState.nestedScrollConnection else drawerState.anchoredDraggableState.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() })
}
}
}
}
private fun Modifier.bottomDrawerAnchoredDraggable(
drawerState: DrawerState,
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)
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -0,0 +1,56 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.animation.core.TweenSpec
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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
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
/**
* Possible values of [DrawerState].
*/
enum class DrawerValue {
/**
* The state of the drawer when it is closed.
*/
Closed,
/**
* The state of the drawer when it is open.
*/
Open,
/**
* The state of the bottom drawer when it is expanded (i.e. at 100% height).
*/
Expanded
}

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

@ -0,0 +1,188 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.foundation.background
import com.microsoft.fluentui.compose.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import com.microsoft.fluentui.compose.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
/**
*
*
* Side drawers block interaction with the rest of an apps content with a scrim.
* They are elevated above most of the apps UI and dont affect the screens layout grid.
*
* @param drawerContent composable that represents content inside the drawer
* @param modifier optional modifier for the drawer
* @param drawerState state of the drawer
* @param drawerShape shape of the drawer sheet
* @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the
* drawer sheet
* @param drawerBackground background color to be used for the drawer sheet
* @param scrimColor color of the scrim that obscures content when the drawer is open
* @param preventDismissalOnScrimClick when true, the drawer will not be dismissed when the scrim is clicked
* @param onScrimClick callback to be invoked when the scrim is clicked
*
* @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width
*/
@Composable
fun HorizontalDrawer(
modifier: Modifier,
behaviorType: BehaviorType,
drawerState: DrawerState,
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
drawerState.positionalThreshold = { fl: Float -> drawerWidth / 2 }
val drawerVelocityThreshold = convertDpToFloat(DrawerVelocityThreshold)
drawerState.velocityThreshold = { drawerVelocityThreshold }
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,
orientation = Orientation.Horizontal,
enabled = false,
reverseDirection = isRtl
),
) {
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,187 @@
package com.microsoft.fluentui.tokenized.drawer
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import com.microsoft.fluentui.compose.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import com.microsoft.fluentui.compose.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
@Composable
fun TopDrawer(
modifier: Modifier,
drawerState: DrawerState,
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.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
)
}
}
}
}
}