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
18
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationVector1D
21 import androidx.compose.animation.core.animateDpAsState
22 import androidx.compose.animation.core.animateFloatAsState
23 import androidx.compose.foundation.Canvas
24 import androidx.compose.foundation.background
25 import androidx.compose.foundation.gestures.Orientation
26 import androidx.compose.foundation.gestures.detectTapGestures
27 import androidx.compose.foundation.gestures.draggable
28 import androidx.compose.foundation.interaction.Interaction
29 import androidx.compose.foundation.interaction.MutableInteractionSource
30 import androidx.compose.foundation.layout.Arrangement
31 import androidx.compose.foundation.layout.Box
32 import androidx.compose.foundation.layout.Spacer
33 import androidx.compose.foundation.layout.WindowInsets
34 import androidx.compose.foundation.layout.WindowInsetsSides
35 import androidx.compose.foundation.layout.fillMaxHeight
36 import androidx.compose.foundation.layout.fillMaxSize
37 import androidx.compose.foundation.layout.imePadding
38 import androidx.compose.foundation.layout.only
39 import androidx.compose.foundation.layout.padding
40 import androidx.compose.foundation.layout.widthIn
41 import androidx.compose.foundation.layout.windowInsetsPadding
42 import androidx.compose.foundation.selection.selectableGroup
43 import androidx.compose.material3.internal.DraggableAnchors
44 import androidx.compose.material3.internal.Strings
45 import androidx.compose.material3.internal.draggableAnchors
46 import androidx.compose.material3.internal.getString
47 import androidx.compose.material3.internal.systemBarsForVisualComponents
48 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
49 import androidx.compose.material3.tokens.NavigationRailBaselineItemTokens
50 import androidx.compose.material3.tokens.NavigationRailCollapsedTokens
51 import androidx.compose.material3.tokens.NavigationRailColorTokens
52 import androidx.compose.material3.tokens.NavigationRailExpandedTokens
53 import androidx.compose.material3.tokens.NavigationRailHorizontalItemTokens
54 import androidx.compose.material3.tokens.NavigationRailVerticalItemTokens
55 import androidx.compose.material3.tokens.ScrimTokens
56 import androidx.compose.runtime.Composable
57 import androidx.compose.runtime.Immutable
58 import androidx.compose.runtime.LaunchedEffect
59 import androidx.compose.runtime.SideEffect
60 import androidx.compose.runtime.derivedStateOf
61 import androidx.compose.runtime.getValue
62 import androidx.compose.runtime.movableContentOf
63 import androidx.compose.runtime.mutableIntStateOf
64 import androidx.compose.runtime.mutableStateOf
65 import androidx.compose.runtime.remember
66 import androidx.compose.runtime.rememberCoroutineScope
67 import androidx.compose.runtime.setValue
68 import androidx.compose.ui.Modifier
69 import androidx.compose.ui.graphics.Color
70 import androidx.compose.ui.graphics.GraphicsLayerScope
71 import androidx.compose.ui.graphics.Shape
72 import androidx.compose.ui.graphics.TransformOrigin
73 import androidx.compose.ui.graphics.graphicsLayer
74 import androidx.compose.ui.graphics.isSpecified
75 import androidx.compose.ui.graphics.takeOrElse
76 import androidx.compose.ui.input.pointer.pointerInput
77 import androidx.compose.ui.layout.Layout
78 import androidx.compose.ui.layout.Measurable
79 import androidx.compose.ui.layout.MeasurePolicy
80 import androidx.compose.ui.layout.MeasureResult
81 import androidx.compose.ui.layout.MeasureScope
82 import androidx.compose.ui.layout.Placeable
83 import androidx.compose.ui.layout.layoutId
84 import androidx.compose.ui.platform.LocalDensity
85 import androidx.compose.ui.platform.LocalLayoutDirection
86 import androidx.compose.ui.semantics.contentDescription
87 import androidx.compose.ui.semantics.isTraversalGroup
88 import androidx.compose.ui.semantics.onClick
89 import androidx.compose.ui.semantics.paneTitle
90 import androidx.compose.ui.semantics.semantics
91 import androidx.compose.ui.unit.Constraints
92 import androidx.compose.ui.unit.Dp
93 import androidx.compose.ui.unit.LayoutDirection
94 import androidx.compose.ui.unit.constrain
95 import androidx.compose.ui.unit.dp
96 import androidx.compose.ui.unit.offset
97 import androidx.compose.ui.util.fastFirst
98 import androidx.compose.ui.util.fastForEachIndexed
99 import androidx.compose.ui.util.fastMap
100 import androidx.compose.ui.util.lerp
101 import kotlin.math.min
102 import kotlinx.coroutines.channels.Channel
103 import kotlinx.coroutines.launch
104
105 /**
106 * Material design wide navigation rail.
107 *
108 * Wide navigation rails provide access to primary destinations in apps when using tablet and
109 * desktop screens.
110 *
111 * 
113 *
114 * 
116 *
117 * The wide navigation rail should be used to display multiple [WideNavigationRailItem]s, each
118 * representing a singular app destination, and, optionally, a header containing a menu button, a
119 * [FloatingActionButton], and/or a logo. Each destination is typically represented by an icon and a
120 * text label.
121 *
122 * The [WideNavigationRail] is collapsed by default, but it also supports being expanded via a
123 * [WideNavigationRailState]. When collapsed, the rail should display three to seven navigation
124 * items. A simple example looks like:
125 *
126 * @sample androidx.compose.material3.samples.WideNavigationRailCollapsedSample
127 *
128 * When expanded, the rail should display at least three navigation items. A simple example looks
129 * like:
130 *
131 * @sample androidx.compose.material3.samples.WideNavigationRailExpandedSample
132 *
133 * The [WideNavigationRail] also supports automatically animating between the collapsed and expanded
134 * values. That can be done like so:
135 *
136 * @sample androidx.compose.material3.samples.WideNavigationRailResponsiveSample
137 *
138 * For a modal variation of the wide navigation rail, see [ModalWideNavigationRail].
139 *
140 * Finally, the [WideNavigationRail] supports setting an [Arrangement.Vertical] for the items, with
141 * [Arrangement.Top] being the default. The header will always be at the top.
142 *
143 * See [WideNavigationRailItem] for configuration specific to each item, and not the overall
144 * [WideNavigationRail] component.
145 *
146 * @param modifier the [Modifier] to be applied to this wide navigation rail
147 * @param state the [WideNavigationRailState] of this wide navigation rail
148 * @param shape defines the shape of this wide navigation rail's container.
149 * @param colors [WideNavigationRailColors] that will be used to resolve the colors used for this
150 * wide navigation rail. See [WideNavigationRailDefaults.colors]
151 * @param header optional header that may hold a [FloatingActionButton] or a logo
152 * @param windowInsets a window insets of the wide navigation rail
153 * @param arrangement the [Arrangement.Vertical] of this wide navigation rail for its content. Note
154 * that if there's a header present, the items will be arranged on the remaining space below it,
155 * except for the center arrangement which considers the entire height of the container
156 * @param content the content of this wide navigation rail, typically [WideNavigationRailItem]s
157 */
158 @ExperimentalMaterial3ExpressiveApi
159 @Composable
160 fun WideNavigationRail(
161 modifier: Modifier = Modifier,
162 state: WideNavigationRailState = rememberWideNavigationRailState(),
163 shape: Shape = WideNavigationRailDefaults.containerShape,
164 colors: WideNavigationRailColors = WideNavigationRailDefaults.colors(),
165 header: @Composable (() -> Unit)? = null,
166 windowInsets: WindowInsets = WideNavigationRailDefaults.windowInsets,
167 arrangement: Arrangement.Vertical = WideNavigationRailDefaults.arrangement,
168 content: @Composable () -> Unit
169 ) {
170 WideNavigationRailLayout(
171 modifier = modifier,
172 isModal = false,
173 expanded = state.targetValue.isExpanded,
174 colors = colors,
175 shape = shape,
176 header = header,
177 windowInsets = windowInsets,
178 arrangement = arrangement,
179 content = content
180 )
181 }
182
183 @Composable
WideNavigationRailLayoutnull184 private fun WideNavigationRailLayout(
185 modifier: Modifier,
186 isModal: Boolean,
187 expanded: Boolean,
188 colors: WideNavigationRailColors,
189 shape: Shape,
190 header: @Composable (() -> Unit)?,
191 windowInsets: WindowInsets,
192 arrangement: Arrangement.Vertical,
193 content: @Composable () -> Unit
194 ) {
195 var currentWidth by remember { mutableIntStateOf(0) }
196 var actualMaxExpandedWidth by remember { mutableIntStateOf(0) }
197 val minimumA11ySize =
198 if (LocalMinimumInteractiveComponentSize.current == Dp.Unspecified) {
199 0.dp
200 } else {
201 LocalMinimumInteractiveComponentSize.current
202 }
203
204 // TODO: Load the motionScheme tokens from the component tokens file.
205 val animationSpec = MotionSchemeKeyTokens.DefaultSpatial.value<Dp>()
206 val modalAnimationSpec = MotionSchemeKeyTokens.FastSpatial.value<Dp>()
207 val minWidth by
208 animateDpAsState(
209 targetValue = if (!expanded) CollapsedRailWidth else ExpandedRailMinWidth,
210 animationSpec = if (!isModal) animationSpec else modalAnimationSpec
211 )
212 val widthFullRange by
213 animateDpAsState(
214 targetValue = if (!expanded) CollapsedRailWidth else ExpandedRailMaxWidth,
215 animationSpec = if (!isModal) animationSpec else modalAnimationSpec
216 )
217 val itemVerticalSpacedBy by
218 animateDpAsState(
219 targetValue = if (!expanded) NavigationRailCollapsedTokens.ItemVerticalSpace else 0.dp,
220 animationSpec = animationSpec
221 )
222 val itemMinHeight by
223 animateDpAsState(
224 targetValue = if (!expanded) TopIconItemMinHeight else minimumA11ySize,
225 animationSpec = animationSpec
226 )
227
228 Surface(
229 color = if (!isModal) colors.containerColor else colors.modalContainerColor,
230 contentColor = colors.contentColor,
231 shape = shape,
232 modifier = modifier,
233 ) {
234 Layout(
235 modifier =
236 Modifier.fillMaxHeight()
237 .windowInsetsPadding(windowInsets)
238 .widthIn(max = ExpandedRailMaxWidth)
239 .padding(top = WNRVerticalPadding)
240 .selectableGroup()
241 .semantics { isTraversalGroup = true },
242 content = {
243 if (header != null) {
244 Box(Modifier.layoutId(HeaderLayoutIdTag)) { header() }
245 }
246 content()
247 },
248 measurePolicy =
249 object : MeasurePolicy {
250 override fun MeasureScope.measure(
251 measurables: List<Measurable>,
252 constraints: Constraints
253 ): MeasureResult {
254 val height = constraints.maxHeight
255 var itemsCount = measurables.size
256 var actualExpandedMinWidth = constraints.minWidth
257 val actualMinWidth =
258 if (constraints.minWidth == 0) {
259 actualExpandedMinWidth =
260 ExpandedRailMinWidth.roundToPx()
261 .coerceAtMost(constraints.maxWidth)
262 minWidth.roundToPx().coerceAtMost(constraints.maxWidth)
263 } else {
264 constraints.minWidth
265 }
266 // If there are no items, rail will be empty.
267 if (itemsCount < 1) {
268 return layout(actualMinWidth, height) {}
269 }
270 val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
271 var itemsMeasurables = measurables
272
273 var constraintsOffset = 0
274 var headerPlaceable: Placeable? = null
275 if (header != null) {
276 headerPlaceable =
277 measurables
278 .fastFirst { it.layoutId == HeaderLayoutIdTag }
279 .measure(looseConstraints)
280 // Header is always first element in measurables list.
281 if (itemsCount > 1)
282 itemsMeasurables = measurables.subList(1, itemsCount)
283 // Real item count doesn't include the header.
284 itemsCount--
285 constraintsOffset = headerPlaceable.height
286 }
287
288 val itemsPlaceables =
289 if (itemsCount > 0) mutableListOf<Placeable>() else null
290 val itemMaxWidthConstraint =
291 if (expanded) looseConstraints.maxWidth else actualMinWidth
292 var expandedItemMaxWidth = 0
293 if (itemsPlaceables != null) {
294 itemsMeasurables.fastMap {
295 val measuredItem =
296 it.measure(
297 looseConstraints
298 .offset(vertical = -constraintsOffset)
299 .constrain(
300 Constraints.fitPrioritizingWidth(
301 minWidth = minimumA11ySize.roundToPx(),
302 minHeight = itemMinHeight.roundToPx(),
303 maxWidth = itemMaxWidthConstraint,
304 maxHeight = looseConstraints.maxHeight,
305 )
306 )
307 )
308 val maxItemWidth = measuredItem.measuredWidth
309 if (expanded && expandedItemMaxWidth < maxItemWidth) {
310 expandedItemMaxWidth =
311 maxItemWidth + ItemHorizontalPadding.roundToPx()
312 }
313 constraintsOffset = measuredItem.height
314 itemsPlaceables.add(measuredItem)
315 }
316 }
317
318 var width = actualMinWidth
319 // Limit collapsed rail to fixed width, but expanded rail can be as wide as
320 // constraints.maxWidth
321 if (expanded) {
322 val widestElementWidth =
323 maxOf(expandedItemMaxWidth, headerPlaceable?.width ?: 0)
324
325 if (
326 widestElementWidth > actualMinWidth &&
327 widestElementWidth > actualExpandedMinWidth
328 ) {
329 val widthConstrain =
330 maxOf(widestElementWidth, actualExpandedMinWidth)
331 .coerceAtMost(constraints.maxWidth)
332 // Use widthFullRange so there's no jump in animation for when the
333 // expanded width has to be wider than actualExpandedMinWidth.
334 width = widthFullRange.roundToPx().coerceAtMost(widthConstrain)
335 actualMaxExpandedWidth = width
336 }
337 } else {
338 if (actualMaxExpandedWidth > 0) {
339 // Use widthFullRange so there's no jump in animation for the case
340 // when the expanded width was wider than actualExpandedMinWidth.
341 width =
342 widthFullRange
343 .roundToPx()
344 .coerceIn(
345 minimumValue = actualMinWidth,
346 maximumValue =
347 currentWidth.coerceAtLeast(actualMinWidth)
348 )
349 }
350 }
351 currentWidth = width
352
353 return layout(width, height) {
354 val railHeight = height - WNRVerticalPadding.roundToPx()
355 var headerOffset = 0
356 if (headerPlaceable != null && headerPlaceable.height > 0) {
357 headerPlaceable.placeRelative(0, 0)
358 headerOffset +=
359 headerPlaceable.height + WNRHeaderPadding.roundToPx()
360 }
361
362 if (itemsPlaceables != null) {
363 val layoutSize =
364 if (arrangement == Arrangement.Center) {
365 // For centered arrangement the items will be centered in
366 // the container, not in the remaining space below the
367 // header.
368 railHeight
369 } else {
370 railHeight - headerOffset
371 }
372 val sizes = IntArray(itemsPlaceables.size)
373 itemsPlaceables.fastForEachIndexed { index, item ->
374 sizes[index] = item.height
375 if (index < itemsPlaceables.size - 1) {
376 sizes[index] += itemVerticalSpacedBy.roundToPx()
377 }
378 }
379 val y = IntArray(itemsPlaceables.size)
380 with(arrangement) { arrange(layoutSize, sizes, y) }
381
382 val offset =
383 if (arrangement == Arrangement.Center) 0 else headerOffset
384 itemsPlaceables.fastForEachIndexed { index, item ->
385 item.placeRelative(0, y[index] + offset)
386 }
387 }
388 }
389 }
390 }
391 )
392 }
393 }
394
395 /**
396 * Material design modal wide navigation rail.
397 *
398 * Wide navigation rails provide access to primary destinations in apps when using tablet and
399 * desktop screens.
400 *
401 * The modal wide navigation rail should be used to display multiple [WideNavigationRailItem]s, each
402 * representing a singular app destination, and, optionally, a header containing a menu button, a
403 * [FloatingActionButton], and/or a logo. Each destination is typically represented by an icon and a
404 * text label.
405 *
406 * The [ModalWideNavigationRail] when collapsed behaves like a collapsed [WideNavigationRail]. When
407 * expanded, the modal wide navigation rail blocks interaction with the rest of an app’s content
408 * with a scrim. It is elevated above the app’s UI and doesn't affect the screen’s layout grid. That
409 * can be achieved like so:
410 *
411 * @sample androidx.compose.material3.samples.ModalWideNavigationRailSample
412 *
413 * For a dismissible [ModalWideNavigationRail], that enters from offscreen instead of expanding from
414 * the collapsed rail, set [hideOnCollapse] to true. That can be achieved like so:
415 *
416 * @sample androidx.compose.material3.samples.DismissibleModalWideNavigationRailSample
417 *
418 * See [WideNavigationRailItem] for configuration specific to each item, and not the overall
419 * [ModalWideNavigationRail] component.
420 *
421 * @param modifier the [Modifier] to be applied to this wide navigation rail
422 * @param state the [WideNavigationRailState] of this wide navigation rail
423 * @param hideOnCollapse whether this wide navigation rail should slide offscreen when it collapses
424 * and be hidden, or stay on screen as a collapsed wide navigation rail (default)
425 * @param collapsedShape the shape of this wide navigation rail's container when it's collapsed
426 * @param expandedShape the shape of this wide navigation rail's container when it's expanded
427 * @param colors [WideNavigationRailColors] that will be used to resolve the colors used for this
428 * wide navigation rail. See [WideNavigationRailDefaults.colors]
429 * @param header optional header that may hold a [FloatingActionButton] or a logo
430 * @param expandedHeaderTopPadding the padding to be applied to the top of the rail. It's usually
431 * needed in order to align the content of the rail between the collapsed and expanded animation
432 * @param windowInsets a window insets of the wide navigation rail
433 * @param arrangement the [Arrangement.Vertical] of this wide navigation rail
434 * @param expandedProperties [ModalWideNavigationRailProperties] for further customization of the
435 * expanded modal wide navigation rail's window behavior
436 * @param content the content of this modal wide navigation rail, usually [WideNavigationRailItem]s
437 */
438 @ExperimentalMaterial3ExpressiveApi
439 @Composable
ModalWideNavigationRailnull440 fun ModalWideNavigationRail(
441 modifier: Modifier = Modifier,
442 state: WideNavigationRailState = rememberWideNavigationRailState(),
443 hideOnCollapse: Boolean = false,
444 collapsedShape: Shape = WideNavigationRailDefaults.containerShape,
445 expandedShape: Shape = WideNavigationRailDefaults.modalContainerShape,
446 colors: WideNavigationRailColors = WideNavigationRailDefaults.colors(),
447 header: @Composable (() -> Unit)? = null,
448 expandedHeaderTopPadding: Dp = 0.dp,
449 windowInsets: WindowInsets = WideNavigationRailDefaults.windowInsets,
450 arrangement: Arrangement.Vertical = WideNavigationRailDefaults.arrangement,
451 expandedProperties: ModalWideNavigationRailProperties =
452 ModalWideNavigationRailDefaults.Properties,
453 content: @Composable () -> Unit
454 ) {
455 val rememberContent =
456 if (hideOnCollapse) {
457 content
458 } else remember(content) { movableContentOf(content) }
459
460 val density = LocalDensity.current
461 // TODO: Load the motionScheme tokens from the component tokens file.
462 val modalStateAnimationSpec = MotionSchemeKeyTokens.DefaultSpatial.value<Float>()
463 val modalState =
464 remember(state) {
465 ModalWideNavigationRailState(
466 state = state,
467 density = density,
468 animationSpec = modalStateAnimationSpec,
469 )
470 }
471 val positionProgress =
472 animateFloatAsState(
473 targetValue = if (!state.targetValue.isExpanded) 0f else 1f,
474 // TODO: Load the motionScheme tokens from the component tokens file.
475 animationSpec = MotionSchemeKeyTokens.DefaultEffects.value()
476 )
477 val isCollapsed: Boolean by remember { derivedStateOf { positionProgress.value == 0f } }
478 val modalExpanded: Boolean by remember { derivedStateOf { positionProgress.value >= 0.3f } }
479 val animateToDismiss: suspend () -> Unit = {
480 if (hideOnCollapse) {
481 modalState.collapse()
482 }
483 state.collapse()
484 }
485
486 val settleToDismiss: suspend (velocity: Float) -> Unit = {
487 if (hideOnCollapse) {
488 modalState.settle(it)
489 if (!modalState.targetValue.isExpanded) state.collapse()
490 }
491 }
492
493 // Display a non modal rail when collapsed.
494 if (!hideOnCollapse && isCollapsed) {
495 WideNavigationRailLayout(
496 modifier = modifier,
497 isModal = false,
498 expanded = false,
499 colors = colors,
500 shape = collapsedShape,
501 header = header,
502 windowInsets = windowInsets,
503 arrangement = arrangement,
504 content = rememberContent
505 )
506 }
507
508 val channel = remember { Channel<Boolean>(Channel.CONFLATED) }
509 if (hideOnCollapse) {
510 LaunchedEffect(channel) {
511 for (target in channel) {
512 val newTarget = channel.tryReceive().getOrNull() ?: target
513 launch {
514 if (newTarget) {
515 modalState.expand()
516 } else {
517 modalState.collapse()
518 }
519 }
520 }
521 }
522 }
523
524 // Display a modal container when expanded.
525 if (!isCollapsed) {
526 if (!hideOnCollapse) {
527 // Have a spacer the size of the collapsed rail so that screen content doesn't shift.
528 Box(Modifier.background(color = colors.containerColor, shape = collapsedShape)) {
529 Spacer(modifier = modifier.widthIn(min = CollapsedRailWidth).fillMaxHeight())
530 }
531 }
532
533 val scope = rememberCoroutineScope()
534 val predictiveBackProgress = remember { Animatable(initialValue = 0f) }
535 val predictiveBackState = remember { RailPredictiveBackState() }
536
537 SideEffect { channel.trySend(state.targetValue.isExpanded) }
538
539 ModalWideNavigationRailDialog(
540 properties = expandedProperties,
541 onDismissRequest = { scope.launch { state.collapse() } },
542 onPredictiveBack = { backEvent ->
543 scope.launch { predictiveBackProgress.snapTo(backEvent) }
544 },
545 onPredictiveBackCancelled = { scope.launch { predictiveBackProgress.animateTo(0f) } },
546 predictiveBackState = predictiveBackState
547 ) {
548 Box(modifier = Modifier.fillMaxSize().imePadding()) {
549 val isScrimVisible =
550 if (hideOnCollapse) {
551 (modalState.targetValue != WideNavigationRailValue.Collapsed)
552 } else {
553 modalExpanded
554 }
555
556 Scrim(
557 color = colors.modalScrimColor,
558 onDismissRequest = animateToDismiss,
559 visible = isScrimVisible
560 )
561
562 ModalWideNavigationRailContent(
563 expanded = hideOnCollapse || modalExpanded,
564 isStandaloneModal = hideOnCollapse,
565 predictiveBackProgress = predictiveBackProgress,
566 predictiveBackState = predictiveBackState,
567 settleToDismiss = settleToDismiss,
568 modifier = modifier,
569 railState = modalState,
570 colors = colors,
571 shape = expandedShape,
572 openModalRailMaxWidth = ExpandedRailMaxWidth,
573 header = {
574 Box(
575 modifier = Modifier.padding(top = expandedHeaderTopPadding),
576 ) {
577 header?.invoke()
578 }
579 },
580 windowInsets = windowInsets,
581 gesturesEnabled = hideOnCollapse,
582 arrangement = arrangement,
583 content = rememberContent
584 )
585 }
586 }
587 }
588 }
589
590 /**
591 * Material Design wide navigation rail item.
592 *
593 * It's recommend for navigation items to always have a text label. A [WideNavigationRailItem]
594 * always displays labels (if they exist) when selected and unselected.
595 *
596 * The [WideNavigationRailItem] supports two different icon positions, top and start, which is
597 * controlled by the [iconPosition] param:
598 * - If the icon position is [NavigationItemIconPosition.Top] the icon will be displayed above the
599 * label. This configuration should be used with collapsed wide navigation rails.
600 * - If the icon position is [NavigationItemIconPosition.Start] the icon will be displayed to the
601 * start of the label. This configuration should be used with expanded wide navigation rails.
602 *
603 * However, if an animated item is desired, the [iconPosition] can be controlled via the expanded
604 * value of the associated [WideNavigationRail] or [ModalWideNavigationRail]. By default, it'll use
605 * the [railExpanded] to follow the configuration described above.
606 *
607 * @param selected whether this item is selected
608 * @param onClick called when this item is clicked
609 * @param icon icon for this item, typically an [Icon]
610 * @param label text label for this item
611 * @param modifier the [Modifier] to be applied to this item
612 * @param enabled controls the enabled state of this item. When `false`, this component will not
613 * respond to user input, and it will appear visually disabled and disabled to accessibility
614 * services.
615 * @param railExpanded whether the associated [WideNavigationRail] is expanded or collapsed
616 * @param iconPosition the [NavigationItemIconPosition] for the icon
617 * @param colors [NavigationItemColors] that will be used to resolve the colors used for this item
618 * in different states. See [WideNavigationRailItemDefaults.colors]
619 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
620 * emitting [Interaction]s for this item. You can use this to change the item's appearance or
621 * preview the item in different states. Note that if `null` is provided, interactions will still
622 * happen internally.
623 */
624 @ExperimentalMaterial3ExpressiveApi
625 @Composable
WideNavigationRailItemnull626 fun WideNavigationRailItem(
627 selected: Boolean,
628 onClick: () -> Unit,
629 icon: @Composable () -> Unit,
630 label: @Composable (() -> Unit)?,
631 modifier: Modifier = Modifier,
632 enabled: Boolean = true,
633 railExpanded: Boolean = false,
634 iconPosition: NavigationItemIconPosition =
635 WideNavigationRailItemDefaults.iconPositionFor(railExpanded),
636 colors: NavigationItemColors = WideNavigationRailItemDefaults.colors(),
637 interactionSource: MutableInteractionSource? = null,
638 ) {
639 @Suppress("NAME_SHADOWING")
640 val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
641
642 AnimatedNavigationItem(
643 selected = selected,
644 onClick = onClick,
645 icon = icon,
646 indicatorShape = NavigationRailBaselineItemTokens.ActiveIndicatorShape.value,
647 topIconIndicatorWidth = NavigationRailVerticalItemTokens.ActiveIndicatorWidth,
648 topIconLabelTextStyle = NavigationRailVerticalItemTokens.LabelTextFont.value,
649 startIconLabelTextStyle = NavigationRailHorizontalItemTokens.LabelTextFont.value,
650 topIconIndicatorHorizontalPadding = ItemTopIconIndicatorHorizontalPadding,
651 topIconIndicatorVerticalPadding = ItemTopIconIndicatorVerticalPadding,
652 topIconIndicatorToLabelVerticalPadding = NavigationRailVerticalItemTokens.IconLabelSpace,
653 startIconIndicatorHorizontalPadding =
654 NavigationRailHorizontalItemTokens.FullWidthLeadingSpace,
655 startIconIndicatorVerticalPadding = ItemStartIconIndicatorVerticalPadding,
656 noLabelIndicatorPadding = WNRItemNoLabelIndicatorPadding,
657 startIconToLabelHorizontalPadding = NavigationRailHorizontalItemTokens.IconLabelSpace,
658 itemHorizontalPadding = ItemHorizontalPadding,
659 colors = colors,
660 modifier = modifier,
661 enabled = enabled,
662 label = label,
663 iconPosition = iconPosition,
664 interactionSource = interactionSource,
665 )
666 }
667
668 /**
669 * Represents the colors of the various elements of a wide navigation rail.
670 *
671 * @param containerColor the color used for the background of a non-modal wide navigation rail. Use
672 * [Color.Transparent] to have no color
673 * @param contentColor the preferred color for content inside a wide navigation rail. Defaults to
674 * either the matching content color for [containerColor], or to the current [LocalContentColor]
675 * if [containerColor] is not a color from the theme
676 * @param modalContainerColor the color used for the background of a modal wide navigation rail. Use
677 * [Color.Transparent] to have no color
678 * @param modalScrimColor the color used for the scrim overlay for background content of a modal
679 * wide navigation rail
680 */
681 @Immutable
682 class WideNavigationRailColors(
683 val containerColor: Color,
684 val contentColor: Color,
685 val modalContainerColor: Color,
686 val modalScrimColor: Color,
687 ) {
688 /**
689 * Returns a copy of this NavigationRailColors, optionally overriding some of the values. This
690 * uses the Color.Unspecified to mean “use the value from the source”.
691 */
copynull692 fun copy(
693 containerColor: Color = this.containerColor,
694 contentColor: Color = this.contentColor,
695 modalContainerColor: Color = this.modalContainerColor,
696 modalScrimColor: Color = this.modalScrimColor,
697 ) =
698 WideNavigationRailColors(
699 containerColor = containerColor.takeOrElse { this.containerColor },
<lambda>null700 contentColor = contentColor.takeOrElse { this.contentColor },
<lambda>null701 modalContainerColor = modalContainerColor.takeOrElse { this.modalContainerColor },
<lambda>null702 modalScrimColor = modalScrimColor.takeOrElse { this.modalScrimColor },
703 )
704
equalsnull705 override fun equals(other: Any?): Boolean {
706 if (this === other) return true
707 if (other == null || other !is WideNavigationRailColors) return false
708
709 if (containerColor != other.containerColor) return false
710 if (contentColor != other.contentColor) return false
711 if (modalContainerColor != other.modalContainerColor) return false
712 if (modalScrimColor != other.modalScrimColor) return false
713
714 return true
715 }
716
hashCodenull717 override fun hashCode(): Int {
718 var result = containerColor.hashCode()
719 result = 31 * result + contentColor.hashCode()
720 result = 31 * result + modalContainerColor.hashCode()
721 result = 31 * result + modalScrimColor.hashCode()
722
723 return result
724 }
725 }
726
727 /** Defaults used in [WideNavigationRail]. */
728 @ExperimentalMaterial3ExpressiveApi
729 object WideNavigationRailDefaults {
730 /** Default container shape of a wide navigation rail. */
731 val containerShape: Shape
732 @Composable get() = NavigationRailCollapsedTokens.ContainerShape.value
733
734 /** Default container shape of a modal wide navigation rail. */
735 val modalContainerShape: Shape
736 @Composable get() = NavigationRailExpandedTokens.ModalContainerShape.value
737
738 /** Default arrangement for a wide navigation rail. */
739 val arrangement: Arrangement.Vertical
740 get() = Arrangement.Top
741
742 /** Default window insets for a wide navigation rail. */
743 val windowInsets: WindowInsets
744 @Composable
745 get() =
746 WindowInsets.systemBarsForVisualComponents.only(
747 WindowInsetsSides.Vertical + WindowInsetsSides.Start
748 )
749
750 /**
751 * Creates a [WideNavigationRailColors] with the provided colors according to the Material
752 * specification.
753 */
colorsnull754 @Composable fun colors() = MaterialTheme.colorScheme.defaultWideWideNavigationRailColors
755
756 /**
757 * Creates a [WideNavigationRailColors] with the provided colors according to the Material
758 * specification.
759 *
760 * @param containerColor the color used for the background of a non-modal wide navigation rail.
761 * @param contentColor the preferred color for content inside a wide navigation rail. Defaults
762 * to either the matching content color for [containerColor], or to the current
763 * [LocalContentColor] if [containerColor] is not a color from the theme
764 * @param modalContainerColor the color used for the background of a modal wide navigation rail.
765 * @param modalScrimColor the color used for the scrim overlay for background content of a modal
766 * wide navigation rail
767 */
768 @Composable
769 fun colors(
770 containerColor: Color = WideNavigationRailDefaults.containerColor,
771 contentColor: Color = contentColorFor(containerColor),
772 modalContainerColor: Color = NavigationRailExpandedTokens.ModalContainerColor.value,
773 modalScrimColor: Color = ScrimTokens.ContainerColor.value.copy(ScrimTokens.ContainerOpacity)
774 ): WideNavigationRailColors =
775 MaterialTheme.colorScheme.defaultWideWideNavigationRailColors.copy(
776 containerColor = containerColor,
777 contentColor = contentColor,
778 modalContainerColor = modalContainerColor,
779 modalScrimColor = modalScrimColor
780 )
781
782 private val containerColor: Color
783 @Composable get() = NavigationRailCollapsedTokens.ContainerColor.value
784
785 private val ColorScheme.defaultWideWideNavigationRailColors: WideNavigationRailColors
786 @Composable
787 get() {
788 return defaultWideWideNavigationRailColorsCached
789 ?: WideNavigationRailColors(
790 containerColor = containerColor,
791 contentColor = contentColorFor(containerColor),
792 modalContainerColor =
793 fromToken(NavigationRailExpandedTokens.ModalContainerColor),
794 modalScrimColor =
795 ScrimTokens.ContainerColor.value.copy(ScrimTokens.ContainerOpacity)
796 )
797 .also { defaultWideWideNavigationRailColorsCached = it }
798 }
799 }
800
801 /** Defaults used in [WideNavigationRailItem]. */
802 @ExperimentalMaterial3ExpressiveApi
803 object WideNavigationRailItemDefaults {
804 /**
805 * The default icon position of a [WideNavigationRailItem] given whether the associated
806 * [WideNavigationRail] is collapsed or expanded.
807 */
iconPositionFornull808 fun iconPositionFor(railExpanded: Boolean) =
809 if (railExpanded) NavigationItemIconPosition.Start else NavigationItemIconPosition.Top
810
811 /**
812 * Creates a [NavigationItemColors] with the provided colors according to the Material
813 * specification.
814 */
815 @Composable fun colors() = MaterialTheme.colorScheme.defaultWideNavigationRailItemColors
816
817 /**
818 * Creates a [NavigationItemColors] with the provided colors according to the Material
819 * specification.
820 *
821 * @param selectedIconColor the color to use for the icon when the item is selected.
822 * @param selectedTextColor the color to use for the text label when the item is selected.
823 * @param selectedIndicatorColor the color to use for the indicator when the item is selected.
824 * @param unselectedIconColor the color to use for the icon when the item is unselected.
825 * @param unselectedTextColor the color to use for the text label when the item is unselected.
826 * @param disabledIconColor the color to use for the icon when the item is disabled.
827 * @param disabledTextColor the color to use for the text label when the item is disabled.
828 * @return the resulting [NavigationItemColors] used for [WideNavigationRailItem]
829 */
830 @Composable
831 fun colors(
832 selectedIconColor: Color = NavigationRailColorTokens.ItemActiveIcon.value,
833 selectedTextColor: Color = NavigationRailColorTokens.ItemActiveLabelText.value,
834 selectedIndicatorColor: Color = NavigationRailColorTokens.ItemActiveIndicator.value,
835 unselectedIconColor: Color = NavigationRailColorTokens.ItemInactiveIcon.value,
836 unselectedTextColor: Color = NavigationRailColorTokens.ItemInactiveLabelText.value,
837 disabledIconColor: Color = unselectedIconColor.copy(alpha = DisabledAlpha),
838 disabledTextColor: Color = unselectedTextColor.copy(alpha = DisabledAlpha),
839 ): NavigationItemColors =
840 MaterialTheme.colorScheme.defaultWideNavigationRailItemColors.copy(
841 selectedIconColor = selectedIconColor,
842 selectedTextColor = selectedTextColor,
843 selectedIndicatorColor = selectedIndicatorColor,
844 unselectedIconColor = unselectedIconColor,
845 unselectedTextColor = unselectedTextColor,
846 disabledIconColor = disabledIconColor,
847 disabledTextColor = disabledTextColor,
848 )
849
850 private val ColorScheme.defaultWideNavigationRailItemColors: NavigationItemColors
851 get() {
852 return defaultWideNavigationRailItemColorsCached
853 ?: NavigationItemColors(
854 selectedIconColor = fromToken(NavigationRailColorTokens.ItemActiveIcon),
855 selectedTextColor =
856 fromToken(NavigationRailColorTokens.ItemActiveLabelText),
857 selectedIndicatorColor =
858 fromToken(NavigationRailColorTokens.ItemActiveIndicator),
859 unselectedIconColor = fromToken(NavigationRailColorTokens.ItemInactiveIcon),
860 unselectedTextColor =
861 fromToken(NavigationRailColorTokens.ItemInactiveLabelText),
862 disabledIconColor =
863 fromToken(NavigationRailColorTokens.ItemInactiveIcon)
864 .copy(alpha = DisabledAlpha),
865 disabledTextColor =
866 fromToken(NavigationRailColorTokens.ItemInactiveLabelText)
867 .copy(alpha = DisabledAlpha),
868 )
869 .also { defaultWideNavigationRailItemColorsCached = it }
870 }
871 }
872
873 /** Default values for [ModalWideNavigationRail]. */
874 @Immutable
875 @ExperimentalMaterial3ExpressiveApi
876 object ModalWideNavigationRailDefaults {
877
878 /** Properties used to customize the window behavior of a [ModalWideNavigationRail]. */
879 val Properties: ModalWideNavigationRailProperties =
880 createDefaultModalWideNavigationRailProperties()
881 }
882
883 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
createDefaultModalWideNavigationRailPropertiesnull884 internal expect fun createDefaultModalWideNavigationRailProperties():
885 ModalWideNavigationRailProperties
886
887 @Immutable
888 @ExperimentalMaterial3ExpressiveApi
889 expect class ModalWideNavigationRailProperties(
890 shouldDismissOnBackPress: Boolean = true,
891 ) {
892 val shouldDismissOnBackPress: Boolean
893 }
894
895 @ExperimentalMaterial3ExpressiveApi
896 @Composable
897 internal expect fun ModalWideNavigationRailDialog(
898 onDismissRequest: () -> Unit,
899 properties: ModalWideNavigationRailProperties,
900 onPredictiveBack: (Float) -> Unit,
901 onPredictiveBackCancelled: () -> Unit,
902 predictiveBackState: RailPredictiveBackState,
903 content: @Composable () -> Unit
904 )
905
906 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
907 @Composable
ModalWideNavigationRailContentnull908 private fun ModalWideNavigationRailContent(
909 expanded: Boolean,
910 isStandaloneModal: Boolean,
911 predictiveBackProgress: Animatable<Float, AnimationVector1D>,
912 predictiveBackState: RailPredictiveBackState,
913 settleToDismiss: suspend (velocity: Float) -> Unit,
914 modifier: Modifier,
915 railState: ModalWideNavigationRailState,
916 colors: WideNavigationRailColors,
917 shape: Shape,
918 openModalRailMaxWidth: Dp,
919 header: @Composable (() -> Unit)?,
920 windowInsets: WindowInsets,
921 gesturesEnabled: Boolean,
922 arrangement: Arrangement.Vertical,
923 content: @Composable () -> Unit
924 ) {
925 val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
926 val railPaneTitle = getString(string = Strings.WideNavigationRailPaneTitle)
927
928 Surface(
929 shape = shape,
930 color = colors.modalContainerColor,
931 modifier =
932 modifier
933 .widthIn(max = openModalRailMaxWidth)
934 .fillMaxHeight()
935 .semantics { paneTitle = railPaneTitle }
936 .graphicsLayer {
937 val progress = predictiveBackProgress.value
938 if (progress <= 0f) {
939 return@graphicsLayer
940 }
941 val offset = railState.currentOffset
942 val width = size.width
943 if (!offset.isNaN() && !width.isNaN() && width != 0f) {
944 // Apply the predictive back animation.
945 scaleX =
946 calculatePredictiveBackScaleX(
947 progress,
948 predictiveBackState.swipeEdgeMatchesRail
949 )
950 scaleY = calculatePredictiveBackScaleY(progress)
951 transformOrigin =
952 TransformOrigin(if (isRtl) 1f else 0f, PredictiveBackPivotFractionY)
953 }
954 }
955 .draggableAnchors(railState.anchoredDraggableState, Orientation.Horizontal) {
956 railSize,
957 _ ->
958 val width = railSize.width.toFloat()
959 val minValue =
960 if (isStandaloneModal) {
961 if (isRtl) width else -width
962 } else {
963 0f
964 }
965 val maxValue = 0f
966 return@draggableAnchors DraggableAnchors {
967 WideNavigationRailValue.Collapsed at minValue
968 WideNavigationRailValue.Expanded at maxValue
969 } to railState.targetValue
970 }
971 .draggable(
972 state = railState.anchoredDraggableState.draggableState,
973 orientation = Orientation.Horizontal,
974 enabled = gesturesEnabled,
975 startDragImmediately = railState.anchoredDraggableState.isAnimationRunning,
976 onDragStopped = { settleToDismiss(it) },
977 )
978 ) {
979 WideNavigationRailLayout(
980 modifier =
981 Modifier.graphicsLayer {
982 val progress = predictiveBackProgress.value
983 if (progress <= 0) {
984 return@graphicsLayer
985 }
986 // Preserve the original aspect ratio and alignment due to the predictive back
987 // animation.
988 val predictiveBackScaleX =
989 calculatePredictiveBackScaleX(
990 progress,
991 predictiveBackState.swipeEdgeMatchesRail
992 )
993 val predictiveBackScaleY = calculatePredictiveBackScaleY(progress)
994 scaleX =
995 if (predictiveBackScaleX != 0f) predictiveBackScaleY / predictiveBackScaleX
996 else 1f
997 transformOrigin =
998 TransformOrigin(if (isRtl) 0f else 1f, PredictiveBackPivotFractionY)
999 },
1000 expanded = expanded,
1001 shape = shape,
1002 colors = colors,
1003 header = header,
1004 windowInsets = windowInsets,
1005 arrangement = arrangement,
1006 isModal = true,
1007 content = content
1008 )
1009 }
1010 }
1011
GraphicsLayerScopenull1012 private fun GraphicsLayerScope.calculatePredictiveBackScaleX(
1013 progress: Float,
1014 swipeEdgeMatchesRail: Boolean,
1015 ): Float {
1016 val width = size.width
1017 return if (width.isNaN() || width == 0f) {
1018 1f
1019 } else {
1020 val scaleXDirection = if (swipeEdgeMatchesRail) 1f else -1f
1021 1f +
1022 (scaleXDirection *
1023 lerp(0f, min(PredictiveBackMaxScaleXDistance.toPx(), width), progress)) / width
1024 }
1025 }
1026
GraphicsLayerScopenull1027 private fun GraphicsLayerScope.calculatePredictiveBackScaleY(
1028 progress: Float,
1029 ): Float {
1030 val height = size.height
1031 return if (height.isNaN() || height == 0f) {
1032 1f
1033 } else {
1034 1f - lerp(0f, min(PredictiveBackMaxScaleYDistance.toPx(), height), progress) / height
1035 }
1036 }
1037
1038 @Composable
Scrimnull1039 private fun Scrim(color: Color, onDismissRequest: suspend () -> Unit, visible: Boolean) {
1040 if (color.isSpecified) {
1041 val alpha by
1042 animateFloatAsState(
1043 targetValue = if (visible) 1f else 0f,
1044 // TODO: Load the motionScheme tokens from the component tokens file.
1045 animationSpec = MotionSchemeKeyTokens.DefaultEffects.value()
1046 )
1047 var dismiss by remember { mutableStateOf(false) }
1048 val closeModalRail = getString(Strings.CloseRail)
1049 val dismissModalRail =
1050 if (visible) {
1051 Modifier.pointerInput(onDismissRequest) { detectTapGestures { dismiss = true } }
1052 .semantics(mergeDescendants = true) {
1053 contentDescription = closeModalRail
1054 onClick {
1055 dismiss = true
1056 true
1057 }
1058 }
1059 } else {
1060 Modifier
1061 }
1062 Canvas(Modifier.fillMaxSize().then(dismissModalRail)) {
1063 drawRect(color = color, alpha = alpha.coerceIn(0f, 1f))
1064 }
1065
1066 LaunchedEffect(dismiss) { if (dismiss) onDismissRequest() }
1067 }
1068 }
1069
1070 /*@VisibleForTesting*/
1071 internal val WNRItemNoLabelIndicatorPadding =
1072 (NavigationRailVerticalItemTokens.ActiveIndicatorWidth -
1073 NavigationRailBaselineItemTokens.IconSize) / 2
1074
1075 private val ItemHorizontalPadding = 20.dp
1076 // Vertical padding between the contents of the wide navigation rail and its top/bottom.
1077 private val WNRVerticalPadding = NavigationRailCollapsedTokens.TopSpace
1078 // Padding at the bottom of the rail's header. This padding will only be added when the header is
1079 // not null and the rail arrangement is Top.
1080 private val WNRHeaderPadding: Dp = NavigationRailBaselineItemTokens.HeaderSpaceMinimum
1081 private val CollapsedRailWidth = NavigationRailCollapsedTokens.ContainerWidth
1082 private val ExpandedRailMinWidth = NavigationRailExpandedTokens.ContainerWidthMinimum
1083 private val ExpandedRailMaxWidth = NavigationRailExpandedTokens.ContainerWidthMaximum
1084 private val TopIconItemMinHeight = NavigationRailBaselineItemTokens.ContainerHeight
1085 private val ItemTopIconIndicatorVerticalPadding =
1086 (NavigationRailVerticalItemTokens.ActiveIndicatorHeight -
1087 NavigationRailBaselineItemTokens.IconSize) / 2
1088 private val ItemTopIconIndicatorHorizontalPadding =
1089 (NavigationRailVerticalItemTokens.ActiveIndicatorWidth -
1090 NavigationRailBaselineItemTokens.IconSize) / 2
1091 private val ItemStartIconIndicatorVerticalPadding =
1092 (NavigationRailHorizontalItemTokens.ActiveIndicatorHeight -
1093 NavigationRailBaselineItemTokens.IconSize) / 2
1094 private val PredictiveBackMaxScaleXDistance = 24.dp
1095 private val PredictiveBackMaxScaleYDistance = 48.dp
1096
1097 private const val PredictiveBackPivotFractionY = 0.5f
1098 private const val HeaderLayoutIdTag: String = "header"
1099