Procházet zdrojové kódy

carousel reimplementation

Milan Jurkulak před 3 roky
rodič
revize
4825176202

+ 49 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/AutoScrollSideEffect.kt

@@ -0,0 +1,49 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.carousel
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.yield
+
+@Composable
+fun AutoScrollSideEffect(
+    autoScrollDurationMillis: Long,
+    itemCount: Int,
+    carouselState: CarouselState,
+    doAutoScroll: Boolean,
+    onAutoScrollChange: (isAutoScrollActive: Boolean) -> Unit = {},
+) {
+    if (autoScrollDurationMillis == Long.MAX_VALUE || autoScrollDurationMillis < 0) {
+        return
+    }
+    val updatedItemCount by rememberUpdatedState(newValue = itemCount)
+    if (doAutoScroll) {
+        LaunchedEffect(carouselState) {
+            while (true) {
+                yield()
+                delay(autoScrollDurationMillis)
+                if (carouselState.activePauseHandlesCount > 0) {
+                    snapshotFlow {
+                        carouselState.activePauseHandlesCount
+                    }.first { pauseHandleCount ->
+                        pauseHandleCount == 0
+                    }
+                }
+                carouselState.moveToNextItem(updatedItemCount)
+            }
+        }
+    }
+    onAutoScrollChange(doAutoScroll)
+}

+ 40 - 61
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/BigCarousel.kt

@@ -10,7 +10,6 @@ package org.mjdev.tvlib.ui.components.carousel
 
 import android.annotation.SuppressLint
 import android.content.res.Configuration
-import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.runtime.Composable
@@ -25,14 +24,12 @@ import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.tv.material3.CardScale
-import androidx.tv.material3.Carousel
-import androidx.tv.material3.CarouselState
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import org.mjdev.tvlib.extensions.ModifierExt.detectSwipe
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 
-@SuppressLint("AutoboxingStateValueProperty")
 @OptIn(ExperimentalTvMaterial3Api::class)
+@SuppressLint("AutoboxingStateValueProperty")
 @Preview
 @Composable
 fun BigCarousel(
@@ -48,67 +45,49 @@ fun BigCarousel(
         else config.screenHeightDp * 0.4f
     }
     val isFocused = remember { mutableStateOf(false) }
-    Box {
-        Carousel(
-            autoScrollDurationMillis = autoScrollDurationMillis,
-            carouselState = carouselState.value,
-            itemCount = items.size,
+    Carousel(
+        autoScrollDurationMillis = autoScrollDurationMillis,
+        carouselState = carouselState.value,
+        itemCount = items.size,
+        modifier = modifier
+            .recomposeHighlighter()
+            .fillMaxWidth()
+            .height(height.dp)
+            .onFocusChanged { state ->
+                isFocused.value = state.isFocused || state.hasFocus
+            }
+            .pointerInput(Unit) {
+                detectSwipe(
+                    onSwipeLeft = {
+                        carouselState.value.moveToNextItem(items.size)
+                    },
+                    onSwipeRight = {
+                        carouselState.value.moveToPreviousItem(items.size)
+                    },
+                )
+            },
+    ) { indexOfCarouselItem ->
+        CarouselCard(
+            item = items[indexOfCarouselItem],
             modifier = modifier
-                .recomposeHighlighter()
                 .fillMaxWidth()
                 .height(height.dp)
-                .pointerInput(Unit) {
-                    detectSwipe(
-                        onSwipeLeft = {
-                            val nextItem = carouselState.value.activeItemIndex + 1
-                            if (nextItem < items.size) {
-                                carouselState.value = CarouselState(nextItem).apply {
-                                    pauseAutoScroll(nextItem).apply {
-                                        if (isFocused.value) {
-                                            resumeAutoScroll()
-                                        }
-                                    }
-                                }
-                            }
-                        },
-                        onSwipeRight = {
-                            val prevItem = carouselState.value.activeItemIndex - 1
-                            if (prevItem >= 0) {
-                                carouselState.value = CarouselState(prevItem).apply {
-                                    pauseAutoScroll(prevItem).apply {
-                                        if (isFocused.value) {
-                                            resumeAutoScroll()
-                                        }
-                                    }
-                                }
-                            }
-                        },
-                    )
-                },
-        ) { indexOfCarouselItem ->
-            CarouselCard(
-                item = items[indexOfCarouselItem],
-                modifier = modifier
-                    .fillMaxWidth()
-                    .height(height.dp)
-                    .onFocusChanged { state ->
-                        val selectedIdx = carouselState.value.activeItemIndex
-                        val selectedItem = items[selectedIdx]
-                        if (state.isFocused || state.hasFocus) {
-                            onItemSelected(selectedItem)
-                        }
-                        isFocused.value = state.isFocused || state.hasFocus
-                    },
-                contentScale = ContentScale.Crop,
-                scale = CardScale.None,
-//                focused = isFocused.value && (carouselState.value.activeItemIndex == indexOfCarouselItem),
-                onFocus = onItemSelected,
-                onClick = {
+                .onFocusChanged { state ->
                     val selectedIdx = carouselState.value.activeItemIndex
                     val selectedItem = items[selectedIdx]
-                    onItemClicked(selectedItem)
-                }
-            )
-        }
+                    if (state.isFocused || state.hasFocus) {
+                        onItemSelected(selectedItem)
+                    }
+                },
+            contentScale = ContentScale.Crop,
+            scale = CardScale.None,
+            focused = isFocused.value && (carouselState.value.activeItemIndex == indexOfCarouselItem),
+            onFocus = onItemSelected,
+            onClick = {
+                val selectedIdx = carouselState.value.activeItemIndex
+                val selectedItem = items[selectedIdx]
+                onItemClicked(selectedItem)
+            }
+        )
     }
 }

+ 143 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/Carousel.kt

@@ -0,0 +1,143 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.carousel
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.accessibility.AccessibilityManager
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+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.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import org.mjdev.tvlib.extensions.ModifierExt.bringIntoViewIfChildrenAreFocused
+import org.mjdev.tvlib.ui.components.carousel.CarouselDefaults.CarouselStateUpdater
+import org.mjdev.tvlib.ui.components.carousel.CarouselDefaults.onAnimationCompletion
+import org.mjdev.tvlib.ui.components.carousel.CarouselDefaults.shouldPerformAutoScroll
+import org.mjdev.tvlib.ui.components.carousel.ModifierExt.carouselSemantics
+import org.mjdev.tvlib.ui.components.carousel.ModifierExt.handleKeyEvents
+
+@OptIn(ExperimentalComposeUiApi::class)
+@SuppressLint("ServiceCast")
+@Suppress("IllegalExperimentalApiUsage")
+@Composable
+fun Carousel(
+    itemCount: Int,
+    modifier: Modifier = Modifier,
+    carouselState: CarouselState = remember { CarouselState() },
+    autoScrollDurationMillis: Long = CarouselDefaults.TimeToDisplayItemMillis,
+    contentTransformStartToEnd: ContentTransform = CarouselDefaults.contentTransform,
+    contentTransformEndToStart: ContentTransform = CarouselDefaults.contentTransform,
+    carouselIndicator:
+    @Composable BoxScope.() -> Unit = {
+        CarouselDefaults.IndicatorRow(
+            itemCount = itemCount,
+            activeItemIndex = carouselState.activeItemIndex,
+            modifier = Modifier
+                .align(Alignment.BottomEnd)
+                .padding(16.dp),
+        )
+    },
+    content: @Composable AnimatedContentScope.(index: Int) -> Unit
+) {
+
+    CarouselStateUpdater(carouselState, itemCount)
+
+    var focusState: FocusState? by remember { mutableStateOf(null) }
+    val focusManager = LocalFocusManager.current
+    val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
+    val carouselOuterBoxFocusRequester = remember { FocusRequester() }
+    var isAutoScrollActive by remember { mutableStateOf(false) }
+    val context = LocalContext.current
+
+    val accessibilityManager = remember {
+        context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+    }
+
+    AutoScrollSideEffect(
+        autoScrollDurationMillis = autoScrollDurationMillis,
+        itemCount = itemCount,
+        carouselState = carouselState,
+        doAutoScroll = shouldPerformAutoScroll(focusState, accessibilityManager),
+        onAutoScrollChange = {
+            isAutoScrollActive = it
+        }
+    )
+
+    Box(modifier = modifier
+        .carouselSemantics(itemCount = itemCount, state = carouselState)
+        .bringIntoViewIfChildrenAreFocused()
+        .focusRequester(carouselOuterBoxFocusRequester)
+        .onFocusChanged {
+            focusState = it
+            if (it.isFocused && isAutoScrollActive) {
+                focusManager.moveFocus(FocusDirection.Enter)
+            }
+        }
+        .handleKeyEvents(
+            carouselState = carouselState,
+            outerBoxFocusRequester = carouselOuterBoxFocusRequester,
+            focusManager = focusManager,
+            itemCount = itemCount,
+            isLtr = isLtr
+        ) {
+            focusState
+        }
+        .focusable()
+    ) {
+        AnimatedContent(
+            targetState = carouselState.activeItemIndex,
+            transitionSpec = {
+                if (carouselState.isMovingBackward) {
+                    contentTransformEndToStart
+                } else {
+                    contentTransformStartToEnd
+                }
+            },
+            label = "CarouselAnimation"
+        ) { activeItemIndex ->
+            LaunchedEffect(Unit) {
+                if (accessibilityManager.isEnabled) {
+                    carouselOuterBoxFocusRequester.requestFocus()
+                }
+                this@AnimatedContent.onAnimationCompletion {
+                    if (!isAutoScrollActive && focusState?.isFocused == true) {
+                        carouselOuterBoxFocusRequester.requestFocus()
+                        focusManager.moveFocus(FocusDirection.Enter)
+                    }
+                }
+            }
+            if (itemCount > 0) {
+                content(if (activeItemIndex < itemCount) activeItemIndex else 0)
+            }
+        }
+        this.carouselIndicator()
+    }
+}

+ 134 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/CarouselDefaults.kt

@@ -0,0 +1,134 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.carousel
+
+import android.view.accessibility.AccessibilityManager
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.flow.first
+
+@Suppress("unused")
+object CarouselDefaults {
+
+    const val TimeToDisplayItemMillis: Long = 5000
+
+    val contentTransform: ContentTransform
+        @Composable get() = fadeIn(animationSpec = tween(100))
+            .togetherWith(fadeOut(animationSpec = tween(100)))
+
+    @Composable
+    fun IndicatorRow(
+        itemCount: Int,
+        activeItemIndex: Int,
+        modifier: Modifier = Modifier,
+        spacing: Dp = 8.dp,
+        indicator: @Composable (isActive: Boolean) -> Unit = { isActive ->
+            val activeColor = Color.White
+            val inactiveColor = activeColor.copy(alpha = 0.3f)
+            Box(
+                modifier = Modifier
+                    .size(8.dp)
+                    .background(
+                        color = if (isActive) activeColor else inactiveColor,
+                        shape = CircleShape,
+                    ),
+            )
+        }
+    ) {
+        Row(
+            horizontalArrangement = Arrangement.spacedBy(spacing),
+            verticalAlignment = Alignment.CenterVertically,
+            modifier = modifier,
+        ) {
+            repeat(itemCount) {
+                val isActive = it == activeItemIndex
+                indicator(isActive)
+            }
+        }
+    }
+
+    @Composable
+    fun CarouselStateUpdater(carouselState: CarouselState, itemCount: Int) {
+        LaunchedEffect(carouselState, itemCount) {
+            if (itemCount != 0) {
+                carouselState.activeItemIndex = Math.floorMod(carouselState.activeItemIndex, itemCount)
+            }
+        }
+    }
+
+    @OptIn(ExperimentalAnimationApi::class)
+    @Suppress("IllegalExperimentalApiUsage")
+    suspend fun AnimatedVisibilityScope.onAnimationCompletion(
+        action: suspend () -> Unit
+    ) {
+        snapshotFlow { transition.currentState == transition.targetState }.first { it }
+        action.invoke()
+    }
+
+    fun shouldPerformAutoScroll(
+        focusState: FocusState?,
+        accessibilityManager: AccessibilityManager
+    ): Boolean {
+        val carouselIsFocused = focusState?.isFocused ?: false
+        val carouselHasFocus = focusState?.hasFocus ?: false
+        return !accessibilityManager.isEnabled && !(carouselIsFocused || carouselHasFocus)
+    }
+
+    interface ScrollPauseHandle {
+        fun resumeAutoScroll()
+    }
+
+    object NoOpScrollPauseHandle : ScrollPauseHandle {
+        override fun resumeAutoScroll() {}
+    }
+
+    class ScrollPauseHandleImpl(private val carouselState: CarouselState) : ScrollPauseHandle {
+        private var active by mutableStateOf(true)
+
+        init {
+            carouselState.activePauseHandlesCount += 1
+        }
+
+        override fun resumeAutoScroll() {
+            if (active) {
+                active = false
+                carouselState.activePauseHandlesCount -= 1
+            }
+        }
+    }
+
+    object KeyEventPropagation {
+        const val StopPropagation = true
+        const val ContinuePropagation = false
+    }
+
+}

+ 50 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/CarouselState.kt

@@ -0,0 +1,50 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.carousel
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.setValue
+import java.lang.Math.floorMod
+
+@Suppress("unused")
+@Stable
+class CarouselState(
+    initialActiveItemIndex: Int = 0,
+) {
+
+    var activePauseHandlesCount by mutableIntStateOf(0)
+    var activeItemIndex by mutableIntStateOf(initialActiveItemIndex)
+    var isMovingBackward = false
+
+    fun pauseAutoScroll(itemIndex: Int): CarouselDefaults.ScrollPauseHandle {
+        if (this.activeItemIndex != itemIndex) {
+            return CarouselDefaults.NoOpScrollPauseHandle
+        }
+        return CarouselDefaults.ScrollPauseHandleImpl(this)
+    }
+
+    fun isFirstItem() = activeItemIndex == 0
+
+    fun isLastItem(itemCount: Int) = activeItemIndex == itemCount - 1
+
+    fun moveToPreviousItem(itemCount: Int) {
+        if (itemCount == 0) return
+        isMovingBackward = true
+        activeItemIndex = floorMod(activeItemIndex - 1, itemCount)
+    }
+
+    fun moveToNextItem(itemCount: Int) {
+        if (itemCount == 0) return
+        isMovingBackward = false
+        activeItemIndex = floorMod(activeItemIndex + 1, itemCount)
+    }
+
+}

+ 156 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/carousel/ModifierExt.kt

@@ -0,0 +1,156 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.carousel
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusDirection.Companion.Left
+import androidx.compose.ui.focus.FocusDirection.Companion.Right
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.semantics.CollectionInfo
+import androidx.compose.ui.semantics.ScrollAxisRange
+import androidx.compose.ui.semantics.collectionInfo
+import androidx.compose.ui.semantics.horizontalScrollAxisRange
+import androidx.compose.ui.semantics.scrollBy
+import androidx.compose.ui.semantics.semantics
+
+object ModifierExt {
+
+    @Composable
+    fun Modifier.carouselSemantics(
+        itemCount: Int,
+        state: CarouselState
+    ): Modifier {
+        return this.then(
+            remember(
+                state,
+                itemCount
+            ) {
+                val accessibilityScrollState = ScrollAxisRange(
+                    value = {
+                        state.activeItemIndex.toFloat()
+                    },
+                    maxValue = {
+                        (itemCount - 1).toFloat()
+                    },
+                    reverseScrolling = false
+                )
+                val scrollByAction: ((x: Float, y: Float) -> Boolean) =
+                    { x, _ ->
+                        when {
+                            x > 0f -> state.moveToNextItem(itemCount)
+                            x < 0f -> state.moveToPreviousItem(itemCount)
+                        }
+                        x != 0f
+                    }
+                Modifier.semantics {
+                    horizontalScrollAxisRange = accessibilityScrollState
+                    scrollBy(action = scrollByAction)
+                    collectionInfo = CollectionInfo(rowCount = 1, columnCount = itemCount)
+                }
+            }
+        )
+    }
+
+    @OptIn(ExperimentalComposeUiApi::class)
+    @Suppress("IllegalExperimentalApiUsage")
+    fun Modifier.handleKeyEvents(
+        carouselState: CarouselState,
+        outerBoxFocusRequester: FocusRequester,
+        focusManager: FocusManager,
+        itemCount: Int,
+        isLtr: Boolean,
+        currentCarouselBoxFocusState: () -> FocusState?
+    ): Modifier = onKeyEvent {
+
+        fun showPreviousItem() {
+            carouselState.moveToPreviousItem(itemCount)
+            outerBoxFocusRequester.requestFocus()
+        }
+
+        fun showNextItem() {
+            carouselState.moveToNextItem(itemCount)
+            outerBoxFocusRequester.requestFocus()
+        }
+
+        fun updateItemBasedOnLayout(direction: FocusDirection, isLtr: Boolean) {
+            when (direction) {
+                Left -> if (isLtr) showPreviousItem() else showNextItem()
+                Right -> if (isLtr) showNextItem() else showPreviousItem()
+            }
+        }
+
+        fun handledHorizontalFocusMove(direction: FocusDirection): Boolean = when {
+            it.nativeKeyEvent.repeatCount > 0 -> CarouselDefaults.KeyEventPropagation.StopPropagation
+            currentCarouselBoxFocusState()?.isFocused == true ->
+                if (shouldFocusExitCarousel(direction, carouselState, itemCount, isLtr)) {
+                    CarouselDefaults.KeyEventPropagation.ContinuePropagation
+                } else {
+                    updateItemBasedOnLayout(direction, isLtr)
+                    CarouselDefaults.KeyEventPropagation.StopPropagation
+                }
+
+            !focusManager.moveFocus(direction) -> {
+                updateItemBasedOnLayout(direction, isLtr)
+                CarouselDefaults.KeyEventPropagation.StopPropagation
+            }
+
+            else -> CarouselDefaults.KeyEventPropagation.StopPropagation
+        }
+        when {
+            it.type == KeyUp -> CarouselDefaults.KeyEventPropagation.ContinuePropagation
+            it.key == Key.Back -> {
+                focusManager.moveFocus(FocusDirection.Exit)
+                CarouselDefaults.KeyEventPropagation.ContinuePropagation
+            }
+
+            it.key == Key.DirectionLeft -> handledHorizontalFocusMove(Left)
+            it.key == Key.DirectionRight -> handledHorizontalFocusMove(Right)
+            else -> CarouselDefaults.KeyEventPropagation.ContinuePropagation
+        }
+    }.focusProperties {
+        exit = {
+            when {
+                shouldFocusExitCarousel(
+                    it,
+                    carouselState,
+                    itemCount,
+                    isLtr
+                ) -> FocusRequester.Default
+
+                else -> FocusRequester.Cancel
+            }
+        }
+    }
+
+    private fun shouldFocusExitCarousel(
+        focusDirection: FocusDirection,
+        carouselState: CarouselState,
+        itemCount: Int,
+        isLtr: Boolean
+    ): Boolean = when {
+        focusDirection == Left && isLtr && !carouselState.isFirstItem() -> false
+        focusDirection == Left && !isLtr && !carouselState.isLastItem(itemCount) -> false
+        focusDirection == Right && isLtr && !carouselState.isLastItem(itemCount) -> false
+        focusDirection == Right && !isLtr && !carouselState.isFirstItem() -> false
+        else -> true
+    }
+
+}