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  * ![Fixed tabs
68  * image](https://developer.android.com/images/reference/androidx/compose/material/fixed-tabs.png)
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  * ![Scrollable tabs
192  * image](https://developer.android.com/images/reference/androidx/compose/material/scrollable-tabs.png)
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