From ae4939d835606ad4048f52fdbe564f15639b0a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yamal=20C=C3=A9sar=20Al-Mahamid=20V=C3=A9lez?= Date: Thu, 11 Apr 2024 15:28:04 +0200 Subject: [PATCH] ANDROID-14497 Implement auto play in Carousel (#342) * ANDROID-14497 Implement auto play in Carousel * ANDROID-14497 auto play only when visible * ANDROID-14497 remove test code * ANDROID-14497 fix carousel indicator jump from last to first card * ANDROID-14497 catalog settings for xml carousel * ANDROID-14497 catalog settings for compose carousel * ANDROID-14497 remove unneeded visibility logic * ANDROID-14497 small revert * ANDROID-14497 update readme * ANDROID-14497 Use constants * ANDROID-14497 Use more constants --- .../ui/classic/components/CarouselFragment.kt | 23 +++++ .../common/ComponentComposeFragment.kt | 1 + .../ui/compose/components/Carousels.kt | 50 ++++++++++- .../res/layout/carousel_fragment_catalog.xml | 50 ++++++++++- .../mistica/carousel/CarouselView.kt | 21 +++++ .../mistica/compose/carousel/Carousel.kt | 89 +++++++++++++++---- .../carousel/CarouselPagerIndicator.kt | 2 +- .../mistica/compose/carousel/README.md | 6 ++ .../mistica/compose/util/VisibilityTracker.kt | 78 ++++++++++++++++ 9 files changed, 296 insertions(+), 24 deletions(-) create mode 100644 library/src/main/java/com/telefonica/mistica/compose/util/VisibilityTracker.kt diff --git a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/CarouselFragment.kt b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/CarouselFragment.kt index db1697d9e..d57d86364 100644 --- a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/CarouselFragment.kt +++ b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/classic/components/CarouselFragment.kt @@ -4,13 +4,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.Toast import androidx.fragment.app.Fragment import com.google.accompanist.pager.PagerState import com.telefonica.mistica.card.mediacard.MediaCardView +import com.telefonica.mistica.carousel.CarouselView import com.telefonica.mistica.catalog.R.drawable.card_image_sample import com.telefonica.mistica.catalog.databinding.CarouselFragmentCatalogBinding import com.telefonica.mistica.compose.carousel.CarouselState +import java.util.concurrent.TimeUnit class CarouselFragment : Fragment() { @@ -37,6 +40,25 @@ class CarouselFragment : Fragment() { binding.carouselPageIndicatorView .setState(carouselState) .setPageCount(MEDIA_CARDS_CAROUSEL_SIZE) + + binding.updateCarousel.setOnClickListener { + binding.carouselContainer.removeAllViews() + val carousel = CarouselView(requireContext(), null).apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) + init(carouselState) + } + binding.carouselContainer.addView(carousel) + } + } + + private fun CarouselView.init(carouselState: CarouselState) { + val autoPlaySpeedSeconds = binding.autoPlaySpeed.text.toString().toLongOrNull() ?: DEFAULT_AUTO_PLAY_SPEED_SECONDS + setContent(getMediaCardsForCarousel()) + .setState(carouselState) + .setItemCount(MEDIA_CARDS_CAROUSEL_SIZE) + .setAutoPlay(binding.autoPlay.isChecked()) + .setAutoPlaySpeed(TimeUnit.SECONDS.toMillis(autoPlaySpeedSeconds)) + .setLoop(binding.loop.isChecked()) } private fun getMediaCardsForCarousel(): List { @@ -60,6 +82,7 @@ class CarouselFragment : Fragment() { private companion object { const val MEDIA_CARDS_CAROUSEL_SIZE = 6 + const val DEFAULT_AUTO_PLAY_SPEED_SECONDS = 5L } } diff --git a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/common/ComponentComposeFragment.kt b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/common/ComponentComposeFragment.kt index cfe4e3465..b8ab174ed 100644 --- a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/common/ComponentComposeFragment.kt +++ b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/common/ComponentComposeFragment.kt @@ -11,6 +11,7 @@ import com.telefonica.mistica.compose.theme.MisticaTheme import com.telefonica.mistica.compose.theme.brand.Brand class ComponentComposeFragment(private val theme: Brand, private val component: @Composable () -> Unit) : Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return ComposeView(requireContext()).apply { setContent { diff --git a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/components/Carousels.kt b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/components/Carousels.kt index 3bad31d5f..5584698f6 100644 --- a/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/components/Carousels.kt +++ b/catalog/src/main/java/com/telefonica/mistica/catalog/ui/compose/components/Carousels.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -18,19 +19,60 @@ import com.telefonica.mistica.compose.card.mediacard.MediaCardImage import com.telefonica.mistica.compose.carousel.Carousel import com.telefonica.mistica.compose.carousel.CarouselPagerIndicator import com.telefonica.mistica.compose.carousel.rememberCarouselState +import com.telefonica.mistica.compose.input.CheckBoxInput +import com.telefonica.mistica.compose.input.TextInput import com.telefonica.mistica.compose.tag.Tag import com.telefonica.mistica.tag.TagView +import java.util.concurrent.TimeUnit + +private const val ITEM_COUNT = 6 +private const val DEFAULT_AUTO_PLAY_SPEED_SECONDS = 5L @Composable fun Carousels() { val carouselState = rememberCarouselState() - val itemCount by remember { mutableStateOf(6)} Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() ) { + var autoPlay by remember { + mutableStateOf(false) + } + CheckBoxInput( + modifier = Modifier.padding(16.dp), + text = "Autoplay", + checked = autoPlay, + onCheckedChange = { autoPlay = it } + ) + var loop by remember { + mutableStateOf(false) + } + CheckBoxInput( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + text = "Loop", + checked = loop, + onCheckedChange = { loop = it } + ) + + var autoPlaySpeed by remember { + mutableStateOf("") + } + TextInput( + modifier = Modifier.padding(horizontal = 16.dp), + value = autoPlaySpeed, + onValueChange = { autoPlaySpeed = it }, + label = "Auto play speed in seconds" + ) + Carousel( - itemCount = itemCount, + modifier = Modifier.padding(top = 8.dp), + itemCount = ITEM_COUNT, carouselState = carouselState, + autoPlay = autoPlay, + autoPlaySpeed = TimeUnit.SECONDS.toMillis(autoPlaySpeed.toLongOrNull() ?: DEFAULT_AUTO_PLAY_SPEED_SECONDS), + loop = loop, ) { page -> CarouselItem(page) } @@ -39,7 +81,7 @@ fun Carousels() { modifier = Modifier .align(Alignment.CenterHorizontally) .padding(16.dp), - pagerCount = itemCount, + pagerCount = ITEM_COUNT, debug = true, ) } diff --git a/catalog/src/main/res/layout/carousel_fragment_catalog.xml b/catalog/src/main/res/layout/carousel_fragment_catalog.xml index c143b95d0..f5f519153 100644 --- a/catalog/src/main/res/layout/carousel_fragment_catalog.xml +++ b/catalog/src/main/res/layout/carousel_fragment_catalog.xml @@ -1,5 +1,6 @@ @@ -13,11 +14,54 @@ android:paddingBottom="40dp" > - + + + + + app:inputType="text" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + app:inputHint="Auto play speed in seconds"/> + + + + + + + Unit)? = null @@ -35,6 +41,18 @@ class CarouselView @JvmOverloads constructor( this.carouselState = carouselState } + fun setAutoPlay(autoPlay: Boolean) = apply { + this.autoPlay = autoPlay + } + + fun setLoop(loop: Boolean) = apply { + this.loop = loop + } + + fun setAutoPlaySpeed(autoPlaySpeed: Long) = apply { + this.autoPlaySpeed = autoPlaySpeed + } + fun setItemCount(itemCount: Int): CarouselView = this.apply { this.itemCount = itemCount } @@ -60,6 +78,9 @@ class CarouselView @JvmOverloads constructor( Carousel( itemCount = itemCount, carouselState = carouselState, + autoPlay = autoPlay, + autoPlaySpeed = autoPlaySpeed, + loop = loop, ) { position -> body?.let { it(position) } ?: data?.get(position)?.let { CarouselItem(it) } } diff --git a/library/src/main/java/com/telefonica/mistica/compose/carousel/Carousel.kt b/library/src/main/java/com/telefonica/mistica/compose/carousel/Carousel.kt index 5a1ab3f60..f86bb16dd 100644 --- a/library/src/main/java/com/telefonica/mistica/compose/carousel/Carousel.kt +++ b/library/src/main/java/com/telefonica/mistica/compose/carousel/Carousel.kt @@ -4,41 +4,98 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager +import com.telefonica.mistica.compose.util.VisibilityTracker +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal const val DEFAULT_AUTO_PLAY_SPEED_MILLIS = 5000L +internal const val DEFAULT_AUTO_PLAY = false +internal const val DEFAULT_LOOP = false -@OptIn(ExperimentalPagerApi::class) @Composable fun Carousel( modifier: Modifier = Modifier, carouselState: CarouselState = rememberCarouselState(), contentPadding: PaddingValues = PaddingValuesWithStartAndEndMargin(carouselState, start = 16.dp, end = 16.dp), itemCount: Int, + autoPlay: Boolean = DEFAULT_AUTO_PLAY, + autoPlaySpeed: Long = DEFAULT_AUTO_PLAY_SPEED_MILLIS, + loop: Boolean = DEFAULT_LOOP, content: @Composable (page: Int) -> Unit, ) { - HorizontalPager( - state = carouselState.pagerState, - contentPadding = contentPadding, - count = itemCount, - ) { page -> - val (start, end) = when (page) { - 0 -> 16.dp to 4.dp - itemCount-1 -> 4.dp to 16.dp - else -> 4.dp to 4.dp + var isVisible by remember { + mutableStateOf(false) + } + VisibilityTracker( + onIsFullyVisible = { isVisible = it } + ) { + AutoPlay( + carouselState = carouselState, + itemCount = itemCount, + autoPlay = autoPlay, + autoPlaySpeed = autoPlaySpeed, + loop = loop, + isVisible = isVisible + ) + HorizontalPager( + modifier = modifier, + state = carouselState.pagerState, + contentPadding = contentPadding, + count = itemCount, + ) { page -> + val (start, end) = when (page) { + 0 -> 16.dp to 4.dp + itemCount - 1 -> 4.dp to 16.dp + else -> 4.dp to 4.dp + } + Box( + modifier = modifier.padding(start = start, end = end) + ) { + content(page) + } } - Box( - modifier = modifier.padding(start = start, end = end, top = 0.dp, bottom = 0.dp) - ) { - content(page) + } +} + +@Composable +private fun AutoPlay( + carouselState: CarouselState, + itemCount: Int, + autoPlay: Boolean, + autoPlaySpeed: Long, + loop: Boolean, + isVisible: Boolean, +) { + val scope = rememberCoroutineScope() + LaunchedEffect(isVisible, autoPlay, autoPlaySpeed, loop, carouselState.currentPage) { + if (autoPlay && isVisible) { + delay(autoPlaySpeed) + + val nextPage = carouselState.currentPage + 1 + if (nextPage < itemCount) { + scope.launch { + carouselState.pagerState.animateScrollToPage(nextPage) + } + } else if (loop) { + scope.launch { + carouselState.pagerState.animateScrollToPage(0) + } + } } } } -@OptIn(ExperimentalPagerApi::class) data class PaddingValuesWithStartAndEndMargin( private val carouselState: CarouselState, private val start: Dp = 4.dp, diff --git a/library/src/main/java/com/telefonica/mistica/compose/carousel/CarouselPagerIndicator.kt b/library/src/main/java/com/telefonica/mistica/compose/carousel/CarouselPagerIndicator.kt index a9752856d..9e8ff9969 100644 --- a/library/src/main/java/com/telefonica/mistica/compose/carousel/CarouselPagerIndicator.kt +++ b/library/src/main/java/com/telefonica/mistica/compose/carousel/CarouselPagerIndicator.kt @@ -304,7 +304,7 @@ internal fun calculateWindowPosition( if (shouldMoveTheBulletToTheEdge) { log("Moving to the edge - $movementDirection") when (movementDirection) { - DECREASE -> visibleWindowState.currentSelected-- + DECREASE -> visibleWindowState.currentSelected = currentSelected INCREASE -> visibleWindowState.currentSelected++ NO_MOVEMENT -> {} } diff --git a/library/src/main/java/com/telefonica/mistica/compose/carousel/README.md b/library/src/main/java/com/telefonica/mistica/compose/carousel/README.md index 710f15cc5..cd18eca67 100644 --- a/library/src/main/java/com/telefonica/mistica/compose/carousel/README.md +++ b/library/src/main/java/com/telefonica/mistica/compose/carousel/README.md @@ -34,6 +34,9 @@ modifier: Modifier = Modifier, carouselState: CarouselState = rememberCarouselState(), contentPadding: PaddingValues = PaddingValuesWithStartAndEndMargin(carouselState, start = 16.dp, end = 16.dp), itemCount: Int, +autoPlay: Boolean = false, +autoPlaySpeed: Long = 5000L, +loop: Boolean = false, content: @Composable (page: Int) -> Unit, ``` @@ -42,6 +45,9 @@ content: @Composable (page: Int) -> Unit, `content` is the composable shown inside the Carousel. +Set `autoPlay` to `true` (`false` by default) to make the carousel automatically swipe to the next card after `autoPlaySpeed` milliseconds (5000 by default). +When the last card is reached and `loop` is set to `true` (`false` by default), the carousel will return to the first card automatically. Note: Cards will only be automatically swiped when carousel is fully visible inside the current viewport. + ## CarouselPagerIndicator composable It has the next parameters: ```kotlin diff --git a/library/src/main/java/com/telefonica/mistica/compose/util/VisibilityTracker.kt b/library/src/main/java/com/telefonica/mistica/compose/util/VisibilityTracker.kt new file mode 100644 index 000000000..4d3a9abbf --- /dev/null +++ b/library/src/main/java/com/telefonica/mistica/compose/util/VisibilityTracker.kt @@ -0,0 +1,78 @@ +package com.telefonica.mistica.compose.util + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver + +@Composable +internal fun VisibilityTracker( + modifier: Modifier = Modifier, + onIsFullyVisible: (isFullyVisible: Boolean) -> Unit, + content: @Composable () -> Unit, +) { + var isResumed by remember { mutableStateOf(false) } + var contentArea by remember { mutableStateOf(Rect.Zero) } + var visibleArea by remember { mutableStateOf(Rect.Zero) } + val visibleRatio by remember { + derivedStateOf { + when { + !isResumed -> 0F + contentArea.isEmpty -> 0F + else -> (visibleArea.width * visibleArea.height) / (contentArea.width * contentArea.height) + } + } + } + + LaunchedEffect(isResumed, visibleRatio) { + onIsFullyVisible(isResumed && visibleRatio >= 1F) + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(key1 = lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + isResumed = event == Lifecycle.Event.ON_RESUME + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + Box( + modifier = modifier.onGloballyPositioned { layoutCoordinates -> + contentArea = layoutCoordinates.toRect() + var intersection = contentArea + var outermostParent = layoutCoordinates + while (outermostParent.parentLayoutCoordinates != null) { + outermostParent = outermostParent.parentLayoutCoordinates!! + intersection = intersection.intersect(outermostParent.toRect()) + } + + visibleArea = intersection + } + ) { + content() + } +} + +private fun LayoutCoordinates.toRect() = Rect( + left = this.positionInWindow().x, + top = this.positionInWindow().y, + right = this.positionInWindow().x + this.size.width, + bottom = this.positionInWindow().y + this.size.height, +) \ No newline at end of file