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