| /* |
| * Copyright 2023 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.wear.compose.material3 |
| |
| import androidx.annotation.FloatRange |
| import androidx.annotation.IntRange |
| import androidx.compose.foundation.layout.Arrangement |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Row |
| import androidx.compose.foundation.layout.Spacer |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.padding |
| import androidx.compose.foundation.layout.size |
| import androidx.compose.foundation.pager.HorizontalPager |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.remember |
| import androidx.compose.ui.Alignment |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.draw.drawWithCache |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.StrokeCap |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.dp |
| import androidx.wear.compose.foundation.CurvedAlignment |
| import androidx.wear.compose.foundation.CurvedDirection |
| import androidx.wear.compose.foundation.CurvedLayout |
| import androidx.wear.compose.foundation.CurvedModifier |
| import androidx.wear.compose.foundation.CurvedScope |
| import androidx.wear.compose.foundation.angularSizeDp |
| import androidx.wear.compose.foundation.background |
| import androidx.wear.compose.foundation.curvedBox |
| import androidx.wear.compose.foundation.curvedRow |
| import androidx.wear.compose.foundation.radialSize |
| import androidx.wear.compose.foundation.size |
| import androidx.wear.compose.foundation.weight |
| import androidx.wear.compose.material3.PageIndicatorDefaults.MaxNumberOfIndicators |
| import androidx.wear.compose.materialcore.PagesState |
| import androidx.wear.compose.materialcore.isLayoutDirectionRtl |
| import androidx.wear.compose.materialcore.isRoundDevice |
| |
| /** |
| * Horizontal page indicator for use with [HorizontalPager], representing |
| * the currently active page and the total number of pages. |
| * Pages are indicated as a Circle shape. |
| * The indicator shows up to six pages individually - |
| * if there are more than six pages, [HorizontalPageIndicator] shows a |
| * half-size indicator to the left or right to indicate that more are available. |
| * |
| * Here's how different positions 0..10 might be visually represented: |
| * "X" is selected item, "O" and "o" full and half size items respectively. |
| * |
| * O X O O O o - 2nd position out of 10. There are no more items on the left but more on the right |
| * o O O O X o - current page could be 6, 7 or 8 out of 10, as there are more possible items |
| * on the left and on the right |
| * o O O O X O - current page is 9 out of 10, as there're no more items on the right |
| * |
| * [HorizontalPageIndicator] is linear or curved, depending on the screen shape |
| * of the device - for circular screens it will be curved, |
| * whilst for square screens it will be linear. |
| * |
| * @sample androidx.wear.compose.material3.samples.HorizontalPageIndicatorSample |
| * |
| * @param pageIndicatorState The state object of a [HorizontalPageIndicator] to be used to |
| * observe the Pager's state. |
| * @param modifier Modifier to be applied to the [HorizontalPageIndicator] |
| * @param selectedColor The color of the selected [HorizontalPageIndicator] item |
| * @param unselectedColor The color of unselected [HorizontalPageIndicator] items. |
| * Defaults to [selectedColor] with 30% alpha |
| * @param indicatorSize The size of each [HorizontalPageIndicator] item in [Dp] |
| * @param spacing The spacing between indicator items in [Dp] |
| **/ |
| @Composable |
| public fun HorizontalPageIndicator( |
| pageIndicatorState: PageIndicatorState, |
| modifier: Modifier = Modifier, |
| selectedColor: Color = MaterialTheme.colorScheme.onBackground, |
| unselectedColor: Color = selectedColor.copy(alpha = 0.3f), |
| indicatorSize: Dp = 6.dp, |
| spacing: Dp = 4.dp |
| ) { |
| val isScreenRound = isRoundDevice() |
| val selectedPage: Int = pageIndicatorState.selectedPageWithOffset().toInt() |
| val offset = pageIndicatorState.selectedPageWithOffset() - selectedPage |
| |
| val pagesOnScreen = Integer.min(MaxNumberOfIndicators, pageIndicatorState.pageCount) |
| val pagesState = remember(pageIndicatorState.pageCount) { |
| PagesState( |
| totalPages = pageIndicatorState.pageCount, |
| pagesOnScreen = pagesOnScreen |
| ) |
| } |
| pagesState.recalculateState(selectedPage, offset) |
| |
| val leftSpacerSize = (indicatorSize + spacing) * pagesState.leftSpacerSizeRatio |
| val rightSpacerSize = (indicatorSize + spacing) * pagesState.rightSpacerSizeRatio |
| |
| if (isScreenRound) { |
| CurvedPageIndicator( |
| modifier = modifier, |
| visibleDotIndex = pagesState.visibleDotIndex, |
| pagesOnScreen = pagesOnScreen, |
| indicator = { page -> |
| curvedIndicator( |
| page = page, |
| size = indicatorSize, |
| unselectedColor = unselectedColor, |
| pagesState = pagesState |
| ) |
| }, |
| itemsSpacer = { curvedSpacer(indicatorSize + spacing) }, |
| selectedIndicator = { |
| curvedSelectedIndicator( |
| indicatorSize = indicatorSize, |
| spacing = spacing, |
| selectedColor = selectedColor, |
| progress = offset |
| ) |
| }, |
| spacerLeft = { curvedSpacer(leftSpacerSize) }, |
| spacerRight = { curvedSpacer(rightSpacerSize) } |
| ) |
| } else { |
| LinearPageIndicator( |
| modifier = modifier, |
| visibleDotIndex = pagesState.visibleDotIndex, |
| pagesOnScreen = pagesOnScreen, |
| indicator = { page -> |
| LinearIndicator( |
| page = page, |
| pagesState = pagesState, |
| unselectedColor = unselectedColor, |
| indicatorSize = indicatorSize, |
| spacing = spacing, |
| ) |
| }, |
| selectedIndicator = { |
| LinearSelectedIndicator( |
| indicatorSize = indicatorSize, |
| spacing = spacing, |
| selectedColor = selectedColor, |
| progress = offset |
| ) |
| }, |
| spacerLeft = { LinearSpacer(leftSpacerSize) }, |
| spacerRight = { LinearSpacer(rightSpacerSize) } |
| ) |
| } |
| } |
| |
| /** |
| * Contains the default values used by [HorizontalPageIndicator] |
| */ |
| internal object PageIndicatorDefaults { |
| |
| val MaxNumberOfIndicators = 6 |
| } |
| |
| // TODO(b/290732498): Add rememberPageIndicatorState for HorizontalPager |
| // once HorizontalPager is stable |
| |
| /** |
| * Creates and remembers [PageIndicatorState] based on [maxPages] and [selectedPageWithOffset] |
| * parameters. |
| */ |
| @ExperimentalWearMaterial3Api |
| @Composable |
| public fun rememberPageIndicatorState( |
| maxPages: Int, |
| selectedPageWithOffset: () -> Float |
| ): PageIndicatorState = |
| remember(maxPages, selectedPageWithOffset) { |
| object : PageIndicatorState { |
| |
| override val selectedPageWithOffset: () -> Float |
| get() = selectedPageWithOffset |
| |
| override val pageCount: Int |
| get() = maxPages |
| } |
| } |
| |
| /** |
| * An interface for connection between Pager and [HorizontalPageIndicator]. |
| */ |
| public interface PageIndicatorState { |
| /** |
| * The currently selected page index with offset. |
| * Integer part represents the selected page index and the fractional part represents |
| * the offset as a fraction of the transition from the selected page |
| * to the next page in the range 0f..1f |
| * |
| * For example 5.5f equals to selectedPage = 5, offset 0.5f |
| * |
| * Changes when a scroll (drag, swipe or fling) between pages happens in Pager. |
| */ |
| @get:FloatRange(from = 0.0) |
| public val selectedPageWithOffset: () -> Float |
| |
| /** |
| * Total number of pages |
| */ |
| @get:IntRange(from = 0) |
| public val pageCount: Int |
| } |
| |
| @Composable |
| private fun LinearPageIndicator( |
| modifier: Modifier, |
| visibleDotIndex: Int, |
| pagesOnScreen: Int, |
| indicator: @Composable (Int) -> Unit, |
| selectedIndicator: @Composable () -> Unit, |
| spacerLeft: @Composable () -> Unit, |
| spacerRight: @Composable () -> Unit |
| ) { |
| Row( |
| modifier = modifier.fillMaxSize(), |
| horizontalArrangement = Arrangement.Center, |
| verticalAlignment = Alignment.Bottom |
| ) { |
| // drawing 1 extra spacer for transition |
| spacerLeft() |
| for (page in 0 until visibleDotIndex) { |
| indicator(page) |
| } |
| Box(contentAlignment = Alignment.Center) { |
| Row(verticalAlignment = Alignment.Bottom) { |
| indicator(visibleDotIndex) |
| indicator(visibleDotIndex + 1) |
| } |
| Box { |
| selectedIndicator() |
| } |
| } |
| for (page in visibleDotIndex + 2..pagesOnScreen) { |
| indicator(page) |
| } |
| spacerRight() |
| } |
| } |
| |
| @Composable |
| private fun LinearSelectedIndicator( |
| indicatorSize: Dp, |
| spacing: Dp, |
| selectedColor: Color, |
| progress: Float |
| ) { |
| val horizontalPadding = spacing / 2 |
| val isRtl = isLayoutDirectionRtl() |
| Spacer( |
| modifier = Modifier |
| .drawWithCache { |
| // Adding 2px to fully cover edges of non-selected indicators |
| val strokeWidth = indicatorSize.toPx() + 2 |
| val startX = horizontalPadding.toPx() + strokeWidth / 2 |
| val endX = this.size.width - horizontalPadding.toPx() - strokeWidth / 2 |
| val drawWidth = endX - startX |
| |
| val startSpacerWeight = (progress * 2 - 1).coerceAtLeast(0f) |
| val endSpacerWeight = (1 - progress * 2).coerceAtLeast(0f) |
| |
| // Adding +1 or -1 for cases when start and end have the same coordinates - |
| // otherwise on APIs <= 26 line will not be drawn |
| val additionalPixel = if (isRtl) -1 else 1 |
| |
| val start = Offset( |
| startX + drawWidth * (if (isRtl) startSpacerWeight else endSpacerWeight) + |
| additionalPixel, |
| this.size.height / 2 |
| ) |
| val end = Offset( |
| endX - drawWidth * (if (isRtl) endSpacerWeight else startSpacerWeight), |
| this.size.height / 2 |
| ) |
| onDrawBehind { |
| drawLine( |
| color = selectedColor, |
| start = start, |
| end = end, |
| cap = StrokeCap.Round, |
| strokeWidth = strokeWidth |
| ) |
| } |
| } |
| ) |
| } |
| |
| @Composable |
| private fun LinearIndicator( |
| page: Int, |
| pagesState: PagesState, |
| unselectedColor: Color, |
| indicatorSize: Dp, |
| spacing: Dp, |
| ) { |
| Spacer( |
| modifier = Modifier |
| .padding(horizontal = spacing / 2) |
| .size(indicatorSize) |
| .drawWithCache { |
| val strokeWidth = indicatorSize.toPx() * pagesState.sizeRatio(page) |
| val start = Offset(strokeWidth / 2 + 1, this.size.height / 2) |
| val end = Offset(strokeWidth / 2, this.size.height / 2) |
| onDrawBehind { |
| drawLine( |
| color = unselectedColor, |
| start = start, |
| end = end, |
| cap = StrokeCap.Round, |
| alpha = pagesState.alpha(page), |
| strokeWidth = strokeWidth |
| ) |
| } |
| } |
| ) |
| } |
| |
| @Composable |
| private fun LinearSpacer(leftSpacerSize: Dp) { |
| Spacer(Modifier.size(leftSpacerSize, 0.dp)) |
| } |
| |
| @Composable |
| private fun CurvedPageIndicator( |
| modifier: Modifier, |
| visibleDotIndex: Int, |
| pagesOnScreen: Int, |
| indicator: CurvedScope.(Int) -> Unit, |
| itemsSpacer: CurvedScope.() -> Unit, |
| selectedIndicator: CurvedScope.() -> Unit, |
| spacerLeft: CurvedScope.() -> Unit, |
| spacerRight: CurvedScope.() -> Unit |
| ) { |
| CurvedLayout( |
| modifier = modifier, |
| // 90 degrees equals to 6 o'clock position, at the bottom of the screen |
| anchor = 90f, |
| angularDirection = CurvedDirection.Angular.Reversed |
| ) { |
| // drawing 1 extra spacer for transition |
| spacerLeft() |
| |
| curvedRow(radialAlignment = CurvedAlignment.Radial.Center) { |
| for (page in 0 until visibleDotIndex) { |
| indicator(page) |
| itemsSpacer() |
| } |
| curvedBox(radialAlignment = CurvedAlignment.Radial.Center) { |
| curvedRow(radialAlignment = CurvedAlignment.Radial.Center) { |
| indicator(visibleDotIndex) |
| itemsSpacer() |
| indicator(visibleDotIndex + 1) |
| } |
| selectedIndicator() |
| } |
| for (page in visibleDotIndex + 2..pagesOnScreen) { |
| itemsSpacer() |
| indicator(page) |
| } |
| } |
| spacerRight() |
| } |
| } |
| |
| private fun CurvedScope.curvedSelectedIndicator( |
| indicatorSize: Dp, |
| spacing: Dp, |
| selectedColor: Color, |
| progress: Float |
| ) { |
| |
| val startSpacerWeight = (1 - progress * 2).coerceAtLeast(0f) |
| val endSpacerWeight = (progress * 2 - 1).coerceAtLeast(0f) |
| val blurbWeight = (1 - startSpacerWeight - endSpacerWeight).coerceAtLeast(0.01f) |
| |
| // Add 0.5dp to cover the sweepDegrees of unselected indicators |
| curvedRow(CurvedModifier.angularSizeDp(spacing + indicatorSize + 0.5.dp)) { |
| if (endSpacerWeight > 0f) { |
| curvedRow(CurvedModifier.weight(endSpacerWeight)) { } |
| } |
| curvedRow( |
| CurvedModifier |
| .background(selectedColor, cap = StrokeCap.Round) |
| .weight(blurbWeight) |
| // Adding 0.3dp to fully cover edges of non-selected indicators |
| .radialSize(indicatorSize + 0.3.dp) |
| ) { } |
| if (startSpacerWeight > 0f) { |
| curvedRow(CurvedModifier.weight(startSpacerWeight)) { } |
| } |
| } |
| } |
| |
| private fun CurvedScope.curvedIndicator( |
| page: Int, |
| unselectedColor: Color, |
| pagesState: PagesState, |
| size: Dp |
| ) { |
| curvedBox( |
| CurvedModifier |
| // Ideally we want sweepDegrees to be = 0f, because the circular shape is drawn |
| // by the Round StrokeCap. |
| // But it can't have 0f value due to limitations of underlying Canvas. |
| // Values below 0.2f also give some artifacts b/291753164 |
| .size(0.2f, size * pagesState.sizeRatio(page)) |
| .background( |
| color = unselectedColor.copy( |
| alpha = unselectedColor.alpha * pagesState.alpha(page) |
| ), |
| cap = StrokeCap.Round |
| ) |
| ) { } |
| } |
| |
| private fun CurvedScope.curvedSpacer(size: Dp) { |
| curvedBox(CurvedModifier.angularSizeDp(size).radialSize(0.dp)) { } |
| } |