1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.material3.adaptive.layout
18
19 import androidx.annotation.FloatRange
20 import androidx.annotation.VisibleForTesting
21 import androidx.collection.MutableLongList
22 import androidx.compose.animation.core.FiniteAnimationSpec
23 import androidx.compose.animation.core.animate
24 import androidx.compose.animation.core.spring
25 import androidx.compose.foundation.MutatePriority
26 import androidx.compose.foundation.MutatorMutex
27 import androidx.compose.foundation.gestures.DragScope
28 import androidx.compose.foundation.gestures.DraggableState
29 import androidx.compose.foundation.gestures.FlingBehavior
30 import androidx.compose.foundation.gestures.ScrollScope
31 import androidx.compose.foundation.gestures.ScrollableDefaults
32 import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
33 import androidx.compose.material3.adaptive.layout.PaneExpansionState.Companion.DefaultAnchoringAnimationSpec
34 import androidx.compose.material3.adaptive.layout.PaneExpansionState.Companion.Unspecified
35 import androidx.compose.material3.adaptive.layout.internal.identityHashCode
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.Immutable
38 import androidx.compose.runtime.LaunchedEffect
39 import androidx.compose.runtime.Stable
40 import androidx.compose.runtime.getValue
41 import androidx.compose.runtime.mutableFloatStateOf
42 import androidx.compose.runtime.mutableIntStateOf
43 import androidx.compose.runtime.mutableStateMapOf
44 import androidx.compose.runtime.mutableStateOf
45 import androidx.compose.runtime.remember
46 import androidx.compose.runtime.saveable.Saver
47 import androidx.compose.runtime.saveable.listSaver
48 import androidx.compose.runtime.saveable.rememberSaveable
49 import androidx.compose.runtime.setValue
50 import androidx.compose.runtime.snapshots.Snapshot
51 import androidx.compose.ui.unit.Density
52 import androidx.compose.ui.unit.Dp
53 import androidx.compose.ui.unit.dp
54 import androidx.compose.ui.util.fastForEach
55 import androidx.compose.ui.util.packInts
56 import androidx.compose.ui.util.unpackInt1
57 import androidx.compose.ui.util.unpackInt2
58 import kotlin.jvm.JvmInline
59 import kotlin.math.abs
60 import kotlin.math.roundToInt
61 import kotlinx.coroutines.coroutineScope
62
63 /**
64 * Interface that provides [PaneExpansionStateKey] to remember and retrieve [PaneExpansionState]
65 * with [rememberPaneExpansionState].
66 */
67 @ExperimentalMaterial3AdaptiveApi
68 @Stable
69 sealed interface PaneExpansionStateKeyProvider {
70 /** The key that represents the unique state of the provider to index [PaneExpansionState]. */
71 val paneExpansionStateKey: PaneExpansionStateKey
72 }
73
74 /**
75 * Interface that serves as keys to remember and retrieve [PaneExpansionState] with
76 * [rememberPaneExpansionState].
77 */
78 @ExperimentalMaterial3AdaptiveApi
79 @Immutable
80 sealed interface PaneExpansionStateKey {
81 private class DefaultImpl : PaneExpansionStateKey {
equalsnull82 override fun equals(other: Any?): Boolean {
83 return this === other
84 }
85
hashCodenull86 override fun hashCode(): Int {
87 return identityHashCode(this)
88 }
89 }
90
91 companion object {
92 /**
93 * The default [PaneExpansionStateKey]. If you want to always share the same
94 * [PaneExpansionState] no matter what current scaffold state is, this key can be used. For
95 * example if the default key is used and a user drag the list-detail layout to a 50-50
96 * split, when the layout switches to, say, detail-extra, it will remain the 50-50 split
97 * instead of using a different (default or user-set) split for it.
98 */
99 val Default: PaneExpansionStateKey = DefaultImpl()
100 }
101 }
102
103 /**
104 * Remembers and returns a [PaneExpansionState] associated to a given
105 * [PaneExpansionStateKeyProvider].
106 *
107 * Note that the remembered [PaneExpansionState] with all keys that have been used will be
108 * persistent through the associated pane scaffold's lifecycles.
109 *
110 * @param keyProvider the provider of [PaneExpansionStateKey]
111 * @param anchors the anchor list of the returned [PaneExpansionState]
112 * @param initialAnchoredIndex the index of the anchor that is supposed to be used during the
113 * initial layout of the associated scaffold; it has to be a valid index of the provided [anchors]
114 * otherwise the function throws; by default the value will be -1 and no initial anchor will be
115 * used.
116 * @param anchoringAnimationSpec the animation spec used to perform anchoring animation; by default
117 * it will be a spring motion.
118 * @param flingBehavior the fling behavior used to handle flings; by default
119 * [ScrollableDefaults.flingBehavior] will be applied.
120 */
121 @ExperimentalMaterial3AdaptiveApi
122 @Composable
rememberPaneExpansionStatenull123 fun rememberPaneExpansionState(
124 keyProvider: PaneExpansionStateKeyProvider,
125 anchors: List<PaneExpansionAnchor> = emptyList(),
126 initialAnchoredIndex: Int = -1,
127 anchoringAnimationSpec: FiniteAnimationSpec<Float> = DefaultAnchoringAnimationSpec,
128 flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior()
129 ): PaneExpansionState =
130 rememberPaneExpansionState(
131 keyProvider.paneExpansionStateKey,
132 anchors,
133 initialAnchoredIndex,
134 anchoringAnimationSpec,
135 flingBehavior
136 )
137
138 /**
139 * Remembers and returns a [PaneExpansionState] associated to a given [PaneExpansionStateKey].
140 *
141 * Note that the remembered [PaneExpansionState] with all keys that have been used will be
142 * persistent through the associated pane scaffold's lifecycles.
143 *
144 * @param key the key of [PaneExpansionStateKey]
145 * @param anchors the anchor list of the returned [PaneExpansionState]
146 * @param initialAnchoredIndex the index of the anchor that is supposed to be used during the
147 * initial layout of the associated scaffold; it has to be a valid index of the provided [anchors]
148 * otherwise the function throws; by default the value will be -1 and no initial anchor will be
149 * used.
150 * @param anchoringAnimationSpec the animation spec used to perform anchoring animation; by default
151 * it will be a spring motion.
152 * @param flingBehavior the fling behavior used to handle flings; by default
153 * [ScrollableDefaults.flingBehavior] will be applied.
154 */
155 @ExperimentalMaterial3AdaptiveApi
156 @Composable
157 fun rememberPaneExpansionState(
158 key: PaneExpansionStateKey = PaneExpansionStateKey.Default,
159 anchors: List<PaneExpansionAnchor> = emptyList(),
160 initialAnchoredIndex: Int = -1,
161 anchoringAnimationSpec: FiniteAnimationSpec<Float> = DefaultAnchoringAnimationSpec,
162 flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior()
163 ): PaneExpansionState {
164 val dataMap = rememberSaveable(saver = PaneExpansionStateSaver()) { mutableStateMapOf() }
165 val initialAnchor =
166 remember(anchors, initialAnchoredIndex) {
167 if (initialAnchoredIndex == -1) null else anchors[initialAnchoredIndex]
168 }
169 val expansionState = remember {
170 PaneExpansionState(
171 dataMap[PaneExpansionStateKey.Default]
172 ?: PaneExpansionStateData(currentAnchor = initialAnchor)
173 )
174 }
175 LaunchedEffect(key, anchors, anchoringAnimationSpec, flingBehavior) {
176 expansionState.restore(
177 dataMap[key]
178 ?: PaneExpansionStateData(currentAnchor = initialAnchor).also { dataMap[key] = it },
179 anchors,
180 anchoringAnimationSpec,
181 flingBehavior
182 )
183 }
184 return expansionState
185 }
186
187 /**
188 * This class manages the pane expansion state for pane scaffolds. By providing and modifying an
189 * instance of this class, you can specify the expanded panes' expansion width or proportion when
190 * pane scaffold is displaying a dual-pane layout.
191 *
192 * This class also serves as the [DraggableState] of pane expansion handle. When a handle
193 * implementation is provided to the associated pane scaffold, the scaffold will use
194 * [PaneExpansionState] to store and manage dragging and anchoring of the handle, and thus the pane
195 * expansion state.
196 */
197 @ExperimentalMaterial3AdaptiveApi
198 @Stable
199 class PaneExpansionState
200 internal constructor(
201 // TODO(conradchen): Handle state change during dragging and settling
202 data: PaneExpansionStateData = PaneExpansionStateData(),
203 anchors: List<PaneExpansionAnchor> = emptyList()
204 ) {
205 internal val firstPaneWidth
206 get() =
207 if (maxExpansionWidth == Unspecified || data.firstPaneWidthState == Unspecified) {
208 Unspecified
209 } else {
210 data.firstPaneWidthState.coerceIn(0, maxExpansionWidth)
211 }
212
213 internal val firstPaneProportion: Float
214 get() = data.firstPaneProportionState
215
216 internal var currentDraggingOffset
217 get() = data.currentDraggingOffsetState
218 private set(value) {
219 val coercedValue = value.coerceIn(0, maxExpansionWidth)
220 if (coercedValue == data.currentDraggingOffsetState) {
221 return
222 }
223 data.currentDraggingOffsetState = coercedValue
224 currentMeasuredDraggingOffset = coercedValue
225 }
226
227 /**
228 * The current anchor that pane expansion has been settled or is settling to. Note that this
229 * field might be `null` if:
230 * 1. No anchors have been set to the state.
231 * 2. Pane expansion is set directly via [setFirstPaneWidth] or set [setFirstPaneProportion].
232 * 3. Pane expansion is in its initial state without an initial anchor provided.
233 */
234 var currentAnchor
235 get() = data.currentAnchorState
236 private set(value) {
237 data.currentAnchorState = value
238 }
239
240 internal val nextAnchor: PaneExpansionAnchor?
241 get() {
242 // maxExpansionWidth will be initialized in onMeasured and is backed by a state. Check
243 // it
244 // here so the next anchor will be updated when measuring is done.
245 if (maxExpansionWidth == Unspecified || anchors.isEmpty()) {
246 return null
247 }
248 val currentOffset =
249 if (currentDraggingOffset == Unspecified) {
250 currentMeasuredDraggingOffset
251 } else {
252 currentDraggingOffset
253 }
indexnull254 measuredAnchorPositions.forEach { index, position ->
255 if (currentOffset < position) {
256 return anchors[index]
257 }
258 }
259 return anchors[0]
260 }
261
262 private var data by mutableStateOf(data)
263
264 internal var isDragging by mutableStateOf(false)
265 private set
266
267 internal var isSettling by mutableStateOf(false)
268 private set
269
270 internal val isDraggingOrSettling
271 get() = isDragging || isSettling
272
273 @VisibleForTesting
274 internal var maxExpansionWidth by mutableIntStateOf(Unspecified)
275 private set
276
277 // Use this field to store the dragging offset decided by measuring instead of dragging to
278 // prevent redundant re-composition.
279 @VisibleForTesting
280 internal var currentMeasuredDraggingOffset = Unspecified
281 private set
282
283 private var anchors: List<PaneExpansionAnchor> by mutableStateOf(anchors)
284
285 internal var measuredAnchorPositions = IndexedAnchorPositionList(0)
286 private set
287
288 private lateinit var anchoringAnimationSpec: FiniteAnimationSpec<Float>
289
290 private lateinit var flingBehavior: FlingBehavior
291
292 private var measuredDensity: Density? = null
293
294 private val dragScope =
295 object : DragScope, ScrollScope {
dragBynull296 override fun dragBy(pixels: Float): Unit = draggableState.dispatchRawDelta(pixels)
297
298 override fun scrollBy(pixels: Float): Float { // To support fling
299 val offsetBeforeDrag = currentDraggingOffset
300 dragBy(pixels)
301 val consumed = currentDraggingOffset - offsetBeforeDrag
302 return consumed.toFloat()
303 }
304 }
305
306 private val dragMutex = MutatorMutex()
307
308 internal val draggableState: DraggableState =
309 object : DraggableState {
dispatchRawDeltanull310 override fun dispatchRawDelta(delta: Float) {
311 if (currentMeasuredDraggingOffset == Unspecified) {
312 return
313 }
314 currentDraggingOffset = (currentMeasuredDraggingOffset + delta).toInt()
315 }
316
dragnull317 override suspend fun drag(
318 dragPriority: MutatePriority,
319 block: suspend DragScope.() -> Unit
320 ) = coroutineScope {
321 isDragging = true
322 dragMutex.mutateWith(dragScope, dragPriority, block)
323 isDragging = false
324 }
325 }
326
327 /** Returns `true` if none of [firstPaneWidth] or [firstPaneProportion] has been set. */
isUnspecifiednull328 fun isUnspecified(): Boolean =
329 firstPaneWidth == Unspecified &&
330 firstPaneProportion.isNaN() &&
331 currentDraggingOffset == Unspecified
332
333 /**
334 * Set the width of the first expanded pane in the layout. When the set value gets applied, it
335 * will be coerced within the range of `[0, the full displayable width of the layout]`.
336 *
337 * Note that setting this value will reset the first pane proportion previously set via
338 * [setFirstPaneProportion] or the current dragging result if there's any. Also if user drags
339 * the pane after setting the first pane width, the user dragging result will take the priority
340 * over this set value when rendering panes, but the set value will be saved.
341 */
342 fun setFirstPaneWidth(firstPaneWidth: Int) {
343 data.firstPaneProportionState = Float.NaN
344 data.currentDraggingOffsetState = Unspecified
345 data.firstPaneWidthState = firstPaneWidth
346 currentAnchor = null
347 }
348
349 /**
350 * Set the proportion of the first expanded pane in the layout. The set value needs to be within
351 * the range of `[0f, 1f]`, otherwise the setter throws.
352 *
353 * Note that setting this value will reset the first pane width previously set via
354 * [setFirstPaneWidth] or the current dragging result if there's any. Also if user drags the
355 * pane after setting the first pane proportion, the user dragging result will take the priority
356 * over this set value when rendering panes, but the set value will be saved.
357 */
setFirstPaneProportionnull358 fun setFirstPaneProportion(@FloatRange(0.0, 1.0) firstPaneProportion: Float) {
359 require(firstPaneProportion in 0f..1f) { "Proportion value needs to be in [0f, 1f]" }
360 data.firstPaneWidthState = Unspecified
361 data.currentDraggingOffsetState = Unspecified
362 data.firstPaneProportionState = firstPaneProportion
363 currentAnchor = null
364 }
365
366 /**
367 * Animate the pane expansion to the given [PaneExpansionAnchor]. Note that the given anchor
368 * must be one of the provided anchor when creating the state with [rememberPaneExpansionState];
369 * otherwise the function throws.
370 *
371 * @param anchor the anchor to animate to
372 * @param initialVelocity the initial velocity of the animation
373 */
animateTonull374 suspend fun animateTo(anchor: PaneExpansionAnchor, initialVelocity: Float = 0F) {
375 require(anchors.contains(anchor)) { "The provided $anchor is not in the anchor list!" }
376 currentAnchor = anchor
377 measuredDensity?.apply {
378 val position = anchor.positionIn(maxExpansionWidth, this)
379 animateToInternal(position, initialVelocity)
380 }
381 }
382
383 /**
384 * Clears any previously set [firstPaneWidth] or [firstPaneProportion], as well as the user
385 * dragging result.
386 */
clearnull387 fun clear() {
388 data.firstPaneWidthState = Unspecified
389 data.firstPaneProportionState = Float.NaN
390 data.currentDraggingOffsetState = Unspecified
391 }
392
restorenull393 internal suspend fun restore(
394 data: PaneExpansionStateData,
395 anchors: List<PaneExpansionAnchor>,
396 anchoringAnimationSpec: FiniteAnimationSpec<Float>,
397 flingBehavior: FlingBehavior
398 ) {
399 dragMutex.mutate(MutatePriority.PreventUserInput) {
400 this.data = data
401 this.anchors = anchors
402 measuredDensity?.let {
403 measuredAnchorPositions =
404 anchors.toPositions(
405 // When maxExpansionWidth is updated, the anchor positions will be
406 // recalculated.
407 maxExpansionWidth,
408 it
409 )
410 }
411 if (!anchors.contains(currentAnchor)) {
412 currentAnchor = null
413 }
414 this.anchoringAnimationSpec = anchoringAnimationSpec
415 this.flingBehavior = flingBehavior
416 }
417 }
418
onMeasurednull419 internal fun onMeasured(measuredWidth: Int, density: Density) {
420 if (measuredWidth == maxExpansionWidth && measuredDensity == density) {
421 return
422 }
423 maxExpansionWidth = measuredWidth
424 measuredDensity = density
425 Snapshot.withoutReadObservation {
426 measuredAnchorPositions = anchors.toPositions(measuredWidth, density)
427 // Changes will always apply to the ongoing measurement, no need to trigger remeasuring
428 currentAnchor?.also { currentDraggingOffset = it.positionIn(measuredWidth, density) }
429 ?: {
430 if (currentDraggingOffset != Unspecified) {
431 // To re-coerce the value
432 currentDraggingOffset = currentDraggingOffset
433 }
434 }
435 }
436 }
437
onExpansionOffsetMeasurednull438 internal fun onExpansionOffsetMeasured(measuredOffset: Int) {
439 currentMeasuredDraggingOffset = measuredOffset
440 }
441
snapToAnchornull442 internal fun snapToAnchor(anchor: PaneExpansionAnchor) {
443 Snapshot.withoutReadObservation {
444 measuredDensity?.let {
445 currentDraggingOffset = anchor.positionIn(maxExpansionWidth, it)
446 }
447 }
448 }
449
settleToAnchorIfNeedednull450 internal suspend fun settleToAnchorIfNeeded(velocity: Float) {
451 if (measuredAnchorPositions.isEmpty()) {
452 return
453 }
454
455 dragMutex.mutate(MutatePriority.PreventUserInput) {
456 try {
457 isSettling = true
458 val leftVelocity = flingBehavior.run { dragScope.performFling(velocity) }
459 val anchorPosition =
460 measuredAnchorPositions.getPositionOfTheClosestAnchor(
461 currentMeasuredDraggingOffset,
462 leftVelocity
463 )
464 currentAnchor = anchors[anchorPosition.index]
465 animateToInternal(anchorPosition.position, leftVelocity)
466 } finally {
467 isSettling = false
468 }
469 }
470 }
471
animateToInternalnull472 private suspend fun animateToInternal(offset: Int, initialVelocity: Float) {
473 try {
474 isSettling = true
475 animate(
476 currentMeasuredDraggingOffset.toFloat(),
477 offset.toFloat(),
478 initialVelocity,
479 anchoringAnimationSpec,
480 ) { value, _ ->
481 currentDraggingOffset = value.toInt()
482 }
483 } finally {
484 currentDraggingOffset = offset
485 isSettling = false
486 }
487 }
488
IndexedAnchorPositionListnull489 private fun IndexedAnchorPositionList.getPositionOfTheClosestAnchor(
490 currentPosition: Int,
491 velocity: Float
492 ): IndexedAnchorPosition =
493 minBy(
494 when {
495 velocity >= AnchoringVelocityThreshold -> {
496 { anchorPosition: Int ->
497 val delta = anchorPosition - currentPosition
498 if (delta < 0) {
499 // If there's no anchor on the swiping direction, use the closet anchor
500 maxExpansionWidth - delta
501 } else {
502 delta
503 }
504 }
505 }
506 velocity <= -AnchoringVelocityThreshold -> {
507 { anchorPosition: Int ->
508 val delta = currentPosition - anchorPosition
509 if (delta < 0) {
510 // If there's no anchor on the swiping direction, use the closet anchor
511 maxExpansionWidth - delta
512 } else {
513 delta
514 }
515 }
516 }
517 else -> {
518 { anchorPosition: Int -> abs(currentPosition - anchorPosition) }
519 }
520 }
521 )
522
523 companion object {
524 /** The constant value used to denote the pane expansion is not specified. */
525 const val Unspecified = -1
526
527 private const val AnchoringVelocityThreshold = 200F
528
529 internal val DefaultAnchoringAnimationSpec =
530 spring(dampingRatio = 0.8f, stiffness = 380f, visibilityThreshold = 1f)
531 }
532 }
533
534 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
535 @Stable
536 internal class PaneExpansionStateData(
537 firstPaneWidth: Int = Unspecified,
538 firstPaneProportion: Float = Float.NaN,
539 currentDraggingOffset: Int = Unspecified,
540 currentAnchor: PaneExpansionAnchor? = null
541 ) {
542 var firstPaneWidthState by mutableIntStateOf(firstPaneWidth)
543 var firstPaneProportionState by mutableFloatStateOf(firstPaneProportion)
544 var currentDraggingOffsetState by mutableIntStateOf(currentDraggingOffset)
545 var currentAnchorState by mutableStateOf(currentAnchor)
546
equalsnull547 override fun equals(other: Any?): Boolean =
548 // TODO(conradchen): Check if we can remove this by directly reading/writing states in
549 // PaneExpansionState
550 Snapshot.withoutReadObservation {
551 if (this === other) return true
552 if (other !is PaneExpansionStateData) return false
553 if (firstPaneWidthState != other.firstPaneWidthState) return false
554 if (firstPaneProportionState != other.firstPaneProportionState) return false
555 if (currentDraggingOffsetState != other.currentDraggingOffsetState) return false
556 if (currentAnchorState != other.currentAnchorState) return false
557 return true
558 }
559
hashCodenull560 override fun hashCode(): Int =
561 // TODO(conradchen): Check if we can remove this by directly reading/writing states in
562 // PaneExpansionState
563 Snapshot.withoutReadObservation {
564 var result = firstPaneWidthState
565 result = 31 * result + firstPaneProportionState.hashCode()
566 result = 31 * result + currentDraggingOffsetState
567 result = 31 * result + currentAnchorState.hashCode()
568 return result
569 }
570 }
571
572 /**
573 * The implementations of this interface represent different types of anchors of pane expansion
574 * dragging. Setting up anchors when create [PaneExpansionState] will force user dragging to snap to
575 * the set anchors after user releases the drag.
576 */
577 @ExperimentalMaterial3AdaptiveApi
578 sealed class PaneExpansionAnchor {
positionInnull579 internal abstract fun positionIn(totalSizePx: Int, density: Density): Int
580
581 internal abstract val type: Int
582
583 /**
584 * The description of the anchor that will be used in
585 * [androidx.compose.ui.semantics.SemanticsProperties] like accessibility services.
586 */
587 @get:Composable abstract val description: String
588
589 /**
590 * [PaneExpansionAnchor] implementation that specifies the anchor position in the proportion of
591 * the total size of the layout at the start side of the anchor.
592 *
593 * @param proportion the proportion of the layout at the start side of the anchor. For example,
594 * if the current layout from the start to the end is list-detail, when the proportion value
595 * is 0.3 and this anchor is used, the list pane will occupy 30% of the layout and the detail
596 * pane will occupy 70% of it.
597 */
598 class Proportion(@FloatRange(0.0, 1.0) val proportion: Float) : PaneExpansionAnchor() {
599 override val type = ProportionType
600
601 override val description
602 @Composable
603 get() =
604 getString(
605 Strings.defaultPaneExpansionProportionAnchorDescription,
606 (proportion * 100).toInt()
607 )
608
609 override fun positionIn(totalSizePx: Int, density: Density) =
610 (totalSizePx * proportion).roundToInt().coerceIn(0, totalSizePx)
611
612 override fun equals(other: Any?): Boolean {
613 if (this === other) return true
614 if (other !is Proportion) return false
615 return proportion == other.proportion
616 }
617
618 override fun hashCode(): Int {
619 return proportion.hashCode()
620 }
621 }
622
623 /**
624 * [PaneExpansionAnchor] implementation that specifies the anchor position based on the offset
625 * in [Dp].
626 *
627 * @property offset the offset of the anchor in [Dp].
628 */
629 abstract class Offset internal constructor(val offset: Dp, override internal val type: Int) :
630 PaneExpansionAnchor() {
631 /**
632 * Indicates the direction of the offset.
633 *
634 * @see Direction.FromStart
635 * @see Direction.FromEnd
636 */
637 val direction: Direction = Direction(type)
638
equalsnull639 override fun equals(other: Any?): Boolean {
640 if (this === other) return true
641 if (other !is Offset) return false
642 return offset == other.offset && direction == other.direction
643 }
644
hashCodenull645 override fun hashCode(): Int {
646 return offset.hashCode() * 31 + direction.hashCode()
647 }
648
649 /** Represents the direction from where the offset will be calculated. */
650 @JvmInline
651 value class Direction internal constructor(internal val value: Int) {
652 companion object {
653 /**
654 * Indicates the offset will be calculated from the start. For example, if the
655 * offset is 150.dp, the resulted anchor will be at the position that is 150dp away
656 * from the start side of the associated layout.
657 */
658 val FromStart = Direction(OffsetFromStartType)
659
660 /**
661 * Indicates the offset will be calculated from the end. For example, if the offset
662 * is 150.dp, the resulted anchor will be at the position that is 150dp away from
663 * the end side of the associated layout.
664 */
665 val FromEnd = Direction(OffsetFromEndType)
666 }
667 }
668
669 private class StartOffset(offset: Dp) : Offset(offset, OffsetFromStartType) {
670 override val description
671 @Composable
672 get() =
673 getString(
674 Strings.defaultPaneExpansionStartOffsetAnchorDescription,
675 offset.value.toInt()
676 )
677
positionInnull678 override fun positionIn(totalSizePx: Int, density: Density) =
679 with(density) { offset.roundToPx() }
680 }
681
682 private class EndOffset(offset: Dp) : Offset(offset, OffsetFromEndType) {
683 override val description
684 @Composable
685 get() =
686 getString(
687 Strings.defaultPaneExpansionEndOffsetAnchorDescription,
688 offset.value.toInt()
689 )
690
positionInnull691 override fun positionIn(totalSizePx: Int, density: Density) =
692 totalSizePx - with(density) { offset.roundToPx() }
693 }
694
695 companion object {
696 /**
697 * Create an [androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset]
698 * anchor from the start side of the layout.
699 *
700 * @param offset offset to be used in [Dp].
701 */
fromStartnull702 fun fromStart(offset: Dp): Offset {
703 require(offset >= 0.dp) { "Offset must larger than or equal to 0 dp." }
704 return StartOffset(offset)
705 }
706
707 /**
708 * Create an [androidx.compose.material3.adaptive.layout.PaneExpansionAnchor.Offset]
709 * anchor from the end side of the layout.
710 *
711 * @param offset offset to be used in [Dp].
712 */
fromEndnull713 fun fromEnd(offset: Dp): Offset {
714 require(offset >= 0.dp) { "Offset must larger than or equal to 0 dp." }
715 return EndOffset(offset)
716 }
717 }
718 }
719
720 internal companion object {
721 internal const val UnspecifiedType = 0
722 internal const val ProportionType = 1
723 internal const val OffsetFromStartType = 2
724 internal const val OffsetFromEndType = 3
725 }
726 }
727
728 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
729 @Composable
rememberDefaultPaneExpansionStatenull730 internal fun rememberDefaultPaneExpansionState(
731 keyProvider: () -> PaneExpansionStateKeyProvider,
732 mutable: Boolean
733 ): PaneExpansionState =
734 if (mutable) {
735 rememberPaneExpansionState(keyProvider())
736 } else {
<lambda>null737 remember { PaneExpansionState() } // Use a stub impl to avoid performance overhead
738 }
739
740 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
741 @VisibleForTesting
PaneExpansionStateSavernull742 internal fun PaneExpansionStateSaver():
743 Saver<MutableMap<PaneExpansionStateKey, PaneExpansionStateData>, *> =
744 listSaver<MutableMap<PaneExpansionStateKey, PaneExpansionStateData>, Any>(
745 save = {
746 val dataSaver = PaneExpansionStateDataSaver()
747 buildList { it.forEach { entry -> add(with(dataSaver) { save(entry) }!!) } }
748 },
<lambda>null749 restore = {
750 val dataSaver = PaneExpansionStateDataSaver()
751 val map = mutableMapOf<PaneExpansionStateKey, PaneExpansionStateData>()
752 it.fastForEach { with(dataSaver) { restore(it) }!!.apply { map[key] = value } }
753 map
754 }
755 )
756
757 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
PaneExpansionStateDataSavernull758 private fun PaneExpansionStateDataSaver():
759 Saver<Map.Entry<PaneExpansionStateKey, PaneExpansionStateData>, Any> =
760 listSaver(
761 save = {
762 val keyType = it.key.type
763 val currentAnchorType =
764 it.value.currentAnchorState?.type ?: PaneExpansionAnchor.UnspecifiedType
765 listOf(
766 keyType,
767 if (keyType == DefaultPaneExpansionStateKey) {
768 null
769 } else {
770 with(TwoPaneExpansionStateKeyImpl.saver()) {
771 save(it.key as TwoPaneExpansionStateKeyImpl)
772 }
773 },
774 it.value.firstPaneWidthState,
775 it.value.firstPaneProportionState,
776 it.value.currentDraggingOffsetState,
777 currentAnchorType,
778 with(it.value.currentAnchorState) {
779 when (this) {
780 is PaneExpansionAnchor.Proportion -> this.proportion
781 is PaneExpansionAnchor.Offset -> this.offset.value
782 else -> null
783 }
784 }
785 )
786 },
<lambda>null787 restore = {
788 val keyType = it[0] as Int
789 val key =
790 if (keyType == DefaultPaneExpansionStateKey || it[1] == null) {
791 PaneExpansionStateKey.Default
792 } else {
793 with(TwoPaneExpansionStateKeyImpl.saver()) { restore(it[1]!!) }
794 }
795 val currentAnchorType = it[5] as Int
796 val currentAnchor =
797 when (currentAnchorType) {
798 PaneExpansionAnchor.ProportionType ->
799 PaneExpansionAnchor.Proportion(it[6] as Float)
800 PaneExpansionAnchor.OffsetFromStartType ->
801 PaneExpansionAnchor.Offset.fromStart((it[6] as Float).dp)
802 PaneExpansionAnchor.OffsetFromEndType ->
803 PaneExpansionAnchor.Offset.fromEnd((it[6] as Float).dp)
804 else -> null
805 }
806 object : Map.Entry<PaneExpansionStateKey, PaneExpansionStateData> {
807 override val key: PaneExpansionStateKey = key!!
808 override val value: PaneExpansionStateData =
809 PaneExpansionStateData(
810 firstPaneWidth = it[2] as Int,
811 firstPaneProportion = it[3] as Float,
812 currentDraggingOffset = it[4] as Int,
813 currentAnchor = currentAnchor
814 )
815 }
816 }
817 )
818
819 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
820 private val PaneExpansionStateKey.type
821 get() =
822 if (this is TwoPaneExpansionStateKeyImpl) {
823 TwoPaneExpansionStateKey
824 } else {
825 DefaultPaneExpansionStateKey
826 }
827
828 private const val DefaultPaneExpansionStateKey = 0
829 private const val TwoPaneExpansionStateKey = 1
830
831 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
toPositionsnull832 private fun List<PaneExpansionAnchor>.toPositions(
833 maxExpansionWidth: Int,
834 density: Density
835 ): IndexedAnchorPositionList {
836 val anchors = IndexedAnchorPositionList(size)
837 @Suppress("ListIterator") // Not necessarily a random-accessible list
838 forEachIndexed { index, anchor ->
839 anchors.add(IndexedAnchorPosition(anchor.positionIn(maxExpansionWidth, density), index))
840 }
841 anchors.sort()
842 return anchors
843 }
844
minBynull845 private fun <T : Comparable<T>> IndexedAnchorPositionList.minBy(
846 selector: (Int) -> T
847 ): IndexedAnchorPosition {
848 if (isEmpty()) {
849 throw NoSuchElementException()
850 }
851 var minElem = this[0]
852 var minValue = selector(minElem.position)
853 for (i in 1 until size) {
854 val elem = this[i]
855 val value = selector(elem.position)
856 if (minValue > value) {
857 minElem = elem
858 minValue = value
859 }
860 }
861 return minElem
862 }
863
864 @JvmInline
865 internal value class IndexedAnchorPositionList(val value: MutableLongList) {
866 constructor(size: Int) : this(MutableLongList(size))
867
868 val size
869 get() = value.size
870
isEmptynull871 fun isEmpty() = value.isEmpty()
872
873 fun add(position: IndexedAnchorPosition) = value.add(position.value)
874
875 fun sort() = value.sort()
876
877 operator fun get(index: Int) = IndexedAnchorPosition(value[index])
878 }
879
880 internal inline fun IndexedAnchorPositionList.forEach(action: (index: Int, position: Int) -> Unit) {
881 value.forEach { with(IndexedAnchorPosition(it)) { action(index, position) } }
882 }
883
884 @JvmInline
885 internal value class IndexedAnchorPosition(val value: Long) {
886 constructor(position: Int, index: Int) : this(packInts(position, index))
887
888 val position
889 get() = unpackInt1(value)
890
891 val index
892 get() = unpackInt2(value)
893 }
894