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:
Коммит
bff6281c5e
|
@ -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 app’s content with a scrim.
|
||||
* They are elevated above most of the app’s UI and don’t affect the screen’s 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче