1 /*
<lambda>null2 * Copyright 2020 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.material
18
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.FastOutSlowInEasing
21 import androidx.compose.animation.core.animateDpAsState
22 import androidx.compose.animation.core.tween
23 import androidx.compose.foundation.ScrollState
24 import androidx.compose.foundation.background
25 import androidx.compose.foundation.horizontalScroll
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.layout.fillMaxWidth
28 import androidx.compose.foundation.layout.height
29 import androidx.compose.foundation.layout.offset
30 import androidx.compose.foundation.layout.width
31 import androidx.compose.foundation.layout.wrapContentSize
32 import androidx.compose.foundation.rememberScrollState
33 import androidx.compose.foundation.selection.selectableGroup
34 import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.Immutable
37 import androidx.compose.runtime.getValue
38 import androidx.compose.runtime.remember
39 import androidx.compose.runtime.rememberCoroutineScope
40 import androidx.compose.ui.Alignment
41 import androidx.compose.ui.Modifier
42 import androidx.compose.ui.UiComposable
43 import androidx.compose.ui.composed
44 import androidx.compose.ui.draw.clipToBounds
45 import androidx.compose.ui.graphics.Color
46 import androidx.compose.ui.layout.SubcomposeLayout
47 import androidx.compose.ui.platform.debugInspectorInfo
48 import androidx.compose.ui.unit.Constraints
49 import androidx.compose.ui.unit.Density
50 import androidx.compose.ui.unit.Dp
51 import androidx.compose.ui.unit.IntOffset
52 import androidx.compose.ui.unit.dp
53 import androidx.compose.ui.util.fastForEach
54 import androidx.compose.ui.util.fastForEachIndexed
55 import androidx.compose.ui.util.fastMap
56 import androidx.compose.ui.util.fastMaxBy
57 import kotlinx.coroutines.CoroutineScope
58 import kotlinx.coroutines.launch
59
60 /**
61 * [Material Design fixed tabs](https://material.io/components/tabs#fixed-tabs)
62 *
63 * Fixed tabs display all tabs in a set simultaneously. They are best for switching between related
64 * content quickly, such as between transportation methods in a map. To navigate between fixed tabs,
65 * tap an individual tab, or swipe left or right in the content area.
66 *
67 * 
69 *
70 * A TabRow contains a row of [Tab]s, and displays an indicator underneath the currently selected
71 * tab. A TabRow places its tabs evenly spaced along the entire row, with each tab taking up an
72 * equal amount of space. See [ScrollableTabRow] for a tab row that does not enforce equal size, and
73 * allows scrolling to tabs that do not fit on screen.
74 *
75 * A simple example with text tabs looks like:
76 *
77 * @sample androidx.compose.material.samples.TextTabs
78 *
79 * You can also provide your own custom tab, such as:
80 *
81 * @sample androidx.compose.material.samples.FancyTabs
82 *
83 * Where the custom tab itself could look like:
84 *
85 * @sample androidx.compose.material.samples.FancyTab
86 *
87 * As well as customizing the tab, you can also provide a custom [indicator], to customize the
88 * indicator displayed for a tab. [indicator] will be placed to fill the entire TabRow, so it should
89 * internally take care of sizing and positioning the indicator to match changes to
90 * [selectedTabIndex].
91 *
92 * For example, given an indicator that draws a rounded rectangle near the edges of the [Tab]:
93 *
94 * @sample androidx.compose.material.samples.FancyIndicator
95 *
96 * We can reuse [TabRowDefaults.tabIndicatorOffset] and just provide this indicator, as we aren't
97 * changing how the size and position of the indicator changes between tabs:
98 *
99 * @sample androidx.compose.material.samples.FancyIndicatorTabs
100 *
101 * You may also want to use a custom transition, to allow you to dynamically change the appearance
102 * of the indicator as it animates between tabs, such as changing its color or size. [indicator] is
103 * stacked on top of the entire TabRow, so you just need to provide a custom transition that
104 * animates the offset of the indicator from the start of the TabRow. For example, take the
105 * following example that uses a transition to animate the offset, width, and color of the same
106 * FancyIndicator from before, also adding a physics based 'spring' effect to the indicator in the
107 * direction of motion:
108 *
109 * @sample androidx.compose.material.samples.FancyAnimatedIndicator
110 *
111 * We can now just pass this indicator directly to TabRow:
112 *
113 * @sample androidx.compose.material.samples.FancyIndicatorContainerTabs
114 * @param selectedTabIndex the index of the currently selected tab
115 * @param modifier optional [Modifier] for this TabRow
116 * @param backgroundColor The background color for the TabRow. Use [Color.Transparent] to have no
117 * color.
118 * @param contentColor The preferred content color provided by this TabRow to its children. Defaults
119 * to either the matching content color for [backgroundColor], or if [backgroundColor] is not a
120 * color from the theme, this will keep the same value set above this TabRow.
121 * @param indicator the indicator that represents which tab is currently selected. By default this
122 * will be a [TabRowDefaults.Indicator], using a [TabRowDefaults.tabIndicatorOffset] modifier to
123 * animate its position. Note that this indicator will be forced to fill up the entire TabRow, so
124 * you should use [TabRowDefaults.tabIndicatorOffset] or similar to animate the actual drawn
125 * indicator inside this space, and provide an offset from the start.
126 * @param divider the divider displayed at the bottom of the TabRow. This provides a layer of
127 * separation between the TabRow and the content displayed underneath.
128 * @param tabs the tabs inside this TabRow. Typically this will be multiple [Tab]s. Each element
129 * inside this lambda will be measured and placed evenly across the TabRow, each taking up equal
130 * space.
131 */
132 @Composable
133 @UiComposable
134 fun TabRow(
135 selectedTabIndex: Int,
136 modifier: Modifier = Modifier,
137 backgroundColor: Color = MaterialTheme.colors.primarySurface,
138 contentColor: Color = contentColorFor(backgroundColor),
139 indicator: @Composable @UiComposable (tabPositions: List<TabPosition>) -> Unit =
140 @Composable { tabPositions ->
141 TabRowDefaults.Indicator(Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]))
142 },
143 divider: @Composable @UiComposable () -> Unit = @Composable { TabRowDefaults.Divider() },
144 tabs: @Composable @UiComposable () -> Unit
145 ) {
146 Surface(
147 modifier = modifier.selectableGroup(),
148 color = backgroundColor,
149 contentColor = contentColor
<lambda>null150 ) {
151 SubcomposeLayout(Modifier.fillMaxWidth()) { constraints ->
152 val tabRowWidth = constraints.maxWidth
153 val tabMeasurables = subcompose(TabSlots.Tabs, tabs)
154 val tabCount = tabMeasurables.size
155 val tabWidth = (tabRowWidth / tabCount)
156 val tabPlaceables =
157 tabMeasurables.fastMap {
158 it.measure(constraints.copy(minWidth = tabWidth, maxWidth = tabWidth))
159 }
160
161 val tabRowHeight = tabPlaceables.fastMaxBy { it.height }?.height ?: 0
162
163 val tabPositions =
164 List(tabCount) { index -> TabPosition(tabWidth.toDp() * index, tabWidth.toDp()) }
165
166 layout(tabRowWidth, tabRowHeight) {
167 tabPlaceables.fastForEachIndexed { index, placeable ->
168 placeable.placeRelative(index * tabWidth, 0)
169 }
170
171 subcompose(TabSlots.Divider, divider).fastForEach {
172 val placeable = it.measure(constraints.copy(minHeight = 0))
173 placeable.placeRelative(0, tabRowHeight - placeable.height)
174 }
175
176 subcompose(TabSlots.Indicator) { indicator(tabPositions) }
177 .fastForEach {
178 it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(0, 0)
179 }
180 }
181 }
182 }
183 }
184
185 /**
186 * [Material Design scrollable tabs](https://material.io/components/tabs#scrollable-tabs)
187 *
188 * When a set of tabs cannot fit on screen, use scrollable tabs. Scrollable tabs can use longer text
189 * labels and a larger number of tabs. They are best used for browsing on touch interfaces.
190 *
191 * 
193 *
194 * A ScrollableTabRow contains a row of [Tab]s, and displays an indicator underneath the currently
195 * selected tab. A ScrollableTabRow places its tabs offset from the starting edge, and allows
196 * scrolling to tabs that are placed off screen. For a fixed tab row that does not allow scrolling,
197 * and evenly places its tabs, see [TabRow].
198 *
199 * @param selectedTabIndex the index of the currently selected tab
200 * @param modifier optional [Modifier] for this ScrollableTabRow
201 * @param backgroundColor The background color for the ScrollableTabRow. Use [Color.Transparent] to
202 * have no color.
203 * @param contentColor The preferred content color provided by this ScrollableTabRow to its
204 * children. Defaults to either the matching content color for [backgroundColor], or if
205 * [backgroundColor] is not a color from the theme, this will keep the same value set above this
206 * ScrollableTabRow.
207 * @param edgePadding the padding between the starting and ending edge of ScrollableTabRow, and the
208 * tabs inside the ScrollableTabRow. This padding helps inform the user that this tab row can be
209 * scrolled, unlike a [TabRow].
210 * @param indicator the indicator that represents which tab is currently selected. By default this
211 * will be a [TabRowDefaults.Indicator], using a [TabRowDefaults.tabIndicatorOffset] modifier to
212 * animate its position. Note that this indicator will be forced to fill up the entire
213 * ScrollableTabRow, so you should use [TabRowDefaults.tabIndicatorOffset] or similar to animate
214 * the actual drawn indicator inside this space, and provide an offset from the start.
215 * @param divider the divider displayed at the bottom of the ScrollableTabRow. This provides a layer
216 * of separation between the ScrollableTabRow and the content displayed underneath.
217 * @param tabs the tabs inside this ScrollableTabRow. Typically this will be multiple [Tab]s. Each
218 * element inside this lambda will be measured and placed evenly across the TabRow, each taking up
219 * equal space.
220 */
221 @Composable
222 @UiComposable
ScrollableTabRownull223 fun ScrollableTabRow(
224 selectedTabIndex: Int,
225 modifier: Modifier = Modifier,
226 backgroundColor: Color = MaterialTheme.colors.primarySurface,
227 contentColor: Color = contentColorFor(backgroundColor),
228 edgePadding: Dp = TabRowDefaults.ScrollableTabRowPadding,
229 indicator: @Composable @UiComposable (tabPositions: List<TabPosition>) -> Unit =
230 @Composable { tabPositions ->
231 TabRowDefaults.Indicator(Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]))
232 },
233 divider: @Composable @UiComposable () -> Unit = @Composable { TabRowDefaults.Divider() },
234 tabs: @Composable @UiComposable () -> Unit
235 ) {
<lambda>null236 Surface(modifier = modifier, color = backgroundColor, contentColor = contentColor) {
237 val scrollState = rememberScrollState()
238 val coroutineScope = rememberCoroutineScope()
239 val scrollableTabData =
240 remember(scrollState, coroutineScope) {
241 ScrollableTabData(scrollState = scrollState, coroutineScope = coroutineScope)
242 }
243 SubcomposeLayout(
244 Modifier.fillMaxWidth()
245 .wrapContentSize(align = Alignment.CenterStart)
246 .horizontalScroll(scrollState)
247 .selectableGroup()
248 .clipToBounds()
249 ) { constraints ->
250 val minTabWidth = ScrollableTabRowMinimumTabWidth.roundToPx()
251 val padding = edgePadding.roundToPx()
252 val tabConstraints = constraints.copy(minWidth = minTabWidth)
253
254 val tabPlaceables =
255 subcompose(TabSlots.Tabs, tabs).fastMap { it.measure(tabConstraints) }
256
257 var layoutWidth = padding * 2
258 var layoutHeight = 0
259 tabPlaceables.fastForEach {
260 layoutWidth += it.width
261 layoutHeight = maxOf(layoutHeight, it.height)
262 }
263
264 // Position the children.
265 layout(layoutWidth, layoutHeight) {
266 // Place the tabs
267 val tabPositions = mutableListOf<TabPosition>()
268 var left = padding
269 tabPlaceables.fastForEach {
270 it.placeRelative(left, 0)
271 tabPositions.add(TabPosition(left = left.toDp(), width = it.width.toDp()))
272 left += it.width
273 }
274
275 // The divider is measured with its own height, and width equal to the total width
276 // of the tab row, and then placed on top of the tabs.
277 subcompose(TabSlots.Divider, divider).fastForEach {
278 val placeable =
279 it.measure(
280 constraints.copy(
281 minHeight = 0,
282 minWidth = layoutWidth,
283 maxWidth = layoutWidth
284 )
285 )
286 placeable.placeRelative(0, layoutHeight - placeable.height)
287 }
288
289 // The indicator container is measured to fill the entire space occupied by the tab
290 // row, and then placed on top of the divider.
291 subcompose(TabSlots.Indicator) { indicator(tabPositions) }
292 .fastForEach {
293 it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0)
294 }
295
296 scrollableTabData.onLaidOut(
297 density = this@SubcomposeLayout,
298 edgeOffset = padding,
299 tabPositions = tabPositions,
300 selectedTab = selectedTabIndex
301 )
302 }
303 }
304 }
305 }
306
307 /**
308 * Data class that contains information about a tab's position on screen, used for calculating where
309 * to place the indicator that shows which tab is selected.
310 *
311 * @property left the left edge's x position from the start of the [TabRow]
312 * @property right the right edge's x position from the start of the [TabRow]
313 * @property width the width of this tab
314 */
315 @Immutable
316 class TabPosition internal constructor(val left: Dp, val width: Dp) {
317 val right: Dp
318 get() = left + width
319
equalsnull320 override fun equals(other: Any?): Boolean {
321 if (this === other) return true
322 if (other !is TabPosition) return false
323
324 if (left != other.left) return false
325 if (width != other.width) return false
326
327 return true
328 }
329
hashCodenull330 override fun hashCode(): Int {
331 var result = left.hashCode()
332 result = 31 * result + width.hashCode()
333 return result
334 }
335
toStringnull336 override fun toString(): String {
337 return "TabPosition(left=$left, right=$right, width=$width)"
338 }
339 }
340
341 /** Contains default implementations and values used for TabRow. */
342 object TabRowDefaults {
343 /**
344 * Default [Divider], which will be positioned at the bottom of the [TabRow], underneath the
345 * indicator.
346 *
347 * @param modifier modifier for the divider's layout
348 * @param thickness thickness of the divider
349 * @param color color of the divider
350 */
351 @Composable
Dividernull352 fun Divider(
353 modifier: Modifier = Modifier,
354 thickness: Dp = DividerThickness,
355 color: Color = LocalContentColor.current.copy(alpha = DividerOpacity)
356 ) {
357 androidx.compose.material.Divider(modifier = modifier, thickness = thickness, color = color)
358 }
359
360 /**
361 * Default indicator, which will be positioned at the bottom of the [TabRow], on top of the
362 * divider.
363 *
364 * @param modifier modifier for the indicator's layout
365 * @param height height of the indicator
366 * @param color color of the indicator
367 */
368 @Composable
Indicatornull369 fun Indicator(
370 modifier: Modifier = Modifier,
371 height: Dp = IndicatorHeight,
372 color: Color = LocalContentColor.current
373 ) {
374 Box(modifier.fillMaxWidth().height(height).background(color = color))
375 }
376
377 /**
378 * [Modifier] that takes up all the available width inside the [TabRow], and then animates the
379 * offset of the indicator it is applied to, depending on the [currentTabPosition].
380 *
381 * @param currentTabPosition [TabPosition] of the currently selected tab. This is used to
382 * calculate the offset of the indicator this modifier is applied to, as well as its width.
383 */
tabIndicatorOffsetnull384 fun Modifier.tabIndicatorOffset(currentTabPosition: TabPosition): Modifier =
385 composed(
386 inspectorInfo =
387 debugInspectorInfo {
388 name = "tabIndicatorOffset"
389 value = currentTabPosition
390 }
<lambda>null391 ) {
392 val currentTabWidth by
393 animateDpAsState(
394 targetValue = currentTabPosition.width,
395 animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
396 )
397 val indicatorOffset by
398 animateDpAsState(
399 targetValue = currentTabPosition.left,
400 animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
401 )
402 fillMaxWidth()
403 .wrapContentSize(Alignment.BottomStart)
404 .offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) }
405 .width(currentTabWidth)
406 }
407
408 /** Default opacity for the color of [Divider] */
409 const val DividerOpacity = 0.12f
410
411 /** Default thickness for [Divider] */
412 val DividerThickness = 1.dp
413
414 /** Default height for [Indicator] */
415 val IndicatorHeight = 2.dp
416
417 /** The default padding from the starting edge before a tab in a [ScrollableTabRow]. */
418 val ScrollableTabRowPadding = 52.dp
419 }
420
421 private enum class TabSlots {
422 Tabs,
423 Divider,
424 Indicator
425 }
426
427 /** Class holding onto state needed for [ScrollableTabRow] */
428 private class ScrollableTabData(
429 private val scrollState: ScrollState,
430 private val coroutineScope: CoroutineScope
431 ) {
432 private var selectedTab: Int? = null
433
onLaidOutnull434 fun onLaidOut(
435 density: Density,
436 edgeOffset: Int,
437 tabPositions: List<TabPosition>,
438 selectedTab: Int
439 ) {
440 // Animate if the new tab is different from the old tab, or this is called for the first
441 // time (i.e selectedTab is `null`).
442 if (this.selectedTab != selectedTab) {
443 this.selectedTab = selectedTab
444 tabPositions.getOrNull(selectedTab)?.let {
445 // Scrolls to the tab with [tabPosition], trying to place it in the center of the
446 // screen or as close to the center as possible.
447 val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
448 if (scrollState.value != calculatedOffset) {
449 coroutineScope.launch {
450 scrollState.animateScrollTo(
451 calculatedOffset,
452 animationSpec = ScrollableTabRowScrollSpec
453 )
454 }
455 }
456 }
457 }
458 }
459
460 /**
461 * @return the offset required to horizontally center the tab inside this TabRow. If the tab is
462 * at the start / end, and there is not enough space to fully centre the tab, this will just
463 * clamp to the min / max position given the max width.
464 */
TabPositionnull465 private fun TabPosition.calculateTabOffset(
466 density: Density,
467 edgeOffset: Int,
468 tabPositions: List<TabPosition>
469 ): Int =
470 with(density) {
471 val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset
472 val visibleWidth = totalTabRowWidth - scrollState.maxValue
473 val tabOffset = left.roundToPx()
474 val scrollerCenter = visibleWidth / 2
475 val tabWidth = width.roundToPx()
476 val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2)
477 // How much space we have to scroll. If the visible width is <= to the total width, then
478 // we have no space to scroll as everything is always visible.
479 val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0)
480 return centeredTabOffset.coerceIn(0, availableSpace)
481 }
482 }
483
484 private val ScrollableTabRowMinimumTabWidth = 90.dp
485
486 /** [AnimationSpec] used when scrolling to a tab that is not fully visible. */
487 private val ScrollableTabRowScrollSpec: AnimationSpec<Float> =
488 tween(durationMillis = 250, easing = FastOutSlowInEasing)
489