| /* |
| * Copyright (C) 2022 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.constraintlayout.compose |
| |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.border |
| import androidx.compose.foundation.gestures.Orientation |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.shape.RoundedCornerShape |
| import androidx.compose.runtime.Composable |
| 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.rememberUpdatedState |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Alignment |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.draw.clip |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.layout.onSizeChanged |
| import androidx.compose.ui.unit.dp |
| import androidx.constraintlayout.compose.MotionLayoutScope.MotionProperties |
| import androidx.constraintlayout.compose.carousel.FractionalThreshold |
| import androidx.constraintlayout.compose.carousel.carouselSwipeable |
| import androidx.constraintlayout.compose.carousel.rememberCarouselSwipeableState |
| |
| /** |
| * Implements an horizontal Carousel of n elements, driven by drag gestures and customizable |
| * through a provided MotionScene. |
| * |
| * Usage |
| * ----- |
| * |
| * val cardsExample = arrayListOf(...) |
| * |
| * MotionCarousel(motionScene...) { |
| * items(cardsExample) { card -> |
| * SomeCardComponent(card) |
| * } |
| * } |
| * |
| * or if wanting to use parameters in your components that are defined in the MotionScene: |
| * |
| * MotionCarousel(motionScene...) { |
| * itemsWithProperties(cardsExample) { card, properties -> |
| * SomeCardComponent(card, properties) |
| * } |
| * } |
| * |
| * Note |
| * ---- |
| * |
| * It is recommended to encapsulate the usage of MotionCarousel: |
| * |
| * fun MyCarousel(content: MotionCarouselScope.() -> Unit) { |
| * val motionScene = ... |
| * MotionCarousel(motionScene..., content) |
| * } |
| * |
| * Mechanism overview and MotionScene architecture |
| * ----------------------------------------------- |
| * |
| * We use 3 different states to represent the Carousel: "previous", "start", and "next". |
| * A horizontal swipe gesture will transition from one state to the other, e.g. a right to left swipe |
| * will transition from "start" to "next". |
| * |
| * We consider a scene containing several "slots" for the elements we want to display in the Carousel. |
| * In an horizontal carousel, the easiest way to think of them is as an horizontal list of slots. |
| * |
| * The overall mechanism thus works by moving those "slots" according to the gesture, and then |
| * mapping the Carousel's elements to the corresponding slots as we progress through the |
| * list of elements. |
| * |
| * For example, let's consider using a Carousel with 3 slots [0] [1] and [2], with [1] the |
| * center slot being the only visible one during the initial state "start" (| and | representing |
| * the screen borders) and [0] being outside of the screen on the left and [2] outside of the screen |
| * on the right: |
| * |
| * start [0] | [1] | [2] |
| * |
| * We can setup the previous state in the following way: |
| * |
| * previous | [0] | [1] [3] |
| * |
| * And the next state like: |
| * |
| * next [0] [1] | [2] | |
| * |
| * All three states together allowing to implement the Carousel motion we are looking for: |
| * |
| * previous | [0] | [1] [3] |
| * start [0] | [1] | [2] |
| * next [0] [1] | [2] | |
| * |
| * At the end of the swipe gesture, we instantly move back to the start state: |
| * |
| * start [0] | [1] | [2] -> gesture starts |
| * next [0] [1] | [2] | -> gesture ends |
| * start [0] | [1] | [2] -> instant snap back to start state |
| * |
| * After the instant snap, we update the elements actually displayed in the slots. |
| * For example, we can start with the elements {a}, {b} and {c} assigned respectively |
| * to the slots [0], [1] and [2]. After the swipe the slots will be reassigned to {b}, {c} and {d}: |
| * |
| * start [0]:{a} | [1]:{b} | [2]:{d} -> gesture starts |
| * next [0]:{a} [1]:{b} | [2]:{c} | -> gesture ends |
| * start [0]:{a} | [1]:{b} | [2]:{c} -> instant snap back to start state |
| * start [0]:{b} | [1]:{c} | [2]:{d} -> repaint with reassigned elements |
| * |
| * In this manner, the overall effect emulate an horizontal scroll of a list of elements. |
| * |
| * A similar mechanism is applied the left to right gesture going through the previous state. |
| * |
| * Starting slot |
| * ------------- |
| * |
| * In order to operate, we need a list of slots. We retrieve them from the motionScene by adding |
| * to the slotPrefix an index number. As the starting slot may not be the first one in the scene, |
| * we also need to be able to specify a startIndex. |
| * |
| * Note that at the beginning of the Carousel, we will not populate the slots that have a lower |
| * index than startIndex, and at the end of the Carousel, we will not populate the slots that have |
| * a higher index than startIndex. |
| * |
| * @param initialSlotIndex the slot index that holds the current element |
| * @param numSlots the number of slots in the scene |
| * @param backwardTransition the name of the previous transition (default "previous") |
| * @param forwardTransition the name of the next transition (default "next") |
| * @param slotPrefix the prefix used for the slots widgets in the scene (default "card") |
| * @param showSlots a debug flag to display the slots in the scene regardless if they are populated |
| * @param content the MotionCarouselScope we use to map the elements to the slots |
| */ |
| @OptIn(ExperimentalMotionApi::class) |
| @Composable |
| @Suppress("UnavailableSymbol") |
| fun MotionCarousel( |
| @Suppress("HiddenTypeParameter") |
| motionScene: MotionScene, |
| initialSlotIndex: Int, |
| numSlots: Int, |
| backwardTransition: String = "backward", |
| forwardTransition: String = "forward", |
| slotPrefix: String = "slot", |
| showSlots: Boolean = false, |
| content: MotionCarouselScope.() -> Unit |
| ) { |
| |
| val swipeStateStart = "start" |
| val swipeStateForward = "next" |
| val swipeStateBackward = "previous" |
| |
| val provider = rememberStateOfItemsProvider(content) |
| |
| var componentWidth by remember { mutableStateOf(1000f) } |
| val swipeableState = rememberCarouselSwipeableState(swipeStateStart) |
| var mprogress = (swipeableState.offset.value / componentWidth) |
| |
| var state by remember { |
| mutableStateOf( |
| CarouselState( |
| MotionCarouselDirection.FORWARD, |
| 0, |
| 0, |
| false, |
| false |
| ) |
| ) |
| } |
| var currentIndex = remember { mutableStateOf(0) } |
| |
| val anchors = if (currentIndex.value == 0) { |
| mapOf(0f to swipeStateStart, componentWidth to swipeStateForward) |
| } else if (currentIndex.value == provider.value.count() - 1) { |
| mapOf(-componentWidth to swipeStateBackward, 0f to swipeStateStart) |
| } else { |
| mapOf( |
| -componentWidth to swipeStateBackward, |
| 0f to swipeStateStart, |
| componentWidth to swipeStateForward |
| ) |
| } |
| |
| val transitionName = remember { |
| mutableStateOf(forwardTransition) |
| } |
| |
| if (mprogress < 0 && state.index > 0) { |
| state.direction = MotionCarouselDirection.BACKWARD |
| transitionName.value = backwardTransition |
| mprogress *= -1 |
| } else { |
| state.direction = MotionCarouselDirection.FORWARD |
| transitionName.value = forwardTransition |
| } |
| |
| if (!swipeableState.isAnimationRunning) { |
| if (state.direction == MotionCarouselDirection.FORWARD && |
| swipeableState.currentValue.equals(swipeStateForward) |
| ) { |
| LaunchedEffect(true) { |
| if (state.index + 1 < provider.value.count()) { |
| state.index++ |
| swipeableState.snapTo(swipeStateStart) |
| state.direction = MotionCarouselDirection.FORWARD |
| } |
| } |
| } else if (state.direction == MotionCarouselDirection.BACKWARD && |
| swipeableState.currentValue.equals(swipeStateBackward) |
| ) { |
| LaunchedEffect(true) { |
| if (state.index > 0) { |
| state.index-- |
| } |
| swipeableState.snapTo(swipeStateStart) |
| state.direction = MotionCarouselDirection.FORWARD |
| } |
| } |
| currentIndex.value = state.index |
| } |
| |
| MotionLayout(motionScene = motionScene, |
| transitionName = transitionName.value, |
| progress = mprogress, |
| motionLayoutFlags = setOf(MotionLayoutFlag.FullMeasure), // TODO: only apply as needed |
| modifier = Modifier |
| .fillMaxSize() |
| .background(Color.White) |
| .carouselSwipeable( |
| state = swipeableState, |
| anchors = anchors, |
| reverseDirection = true, |
| // TODO: replace this implementation? |
| thresholds = { _, _ -> FractionalThreshold(0.3f) }, |
| orientation = Orientation.Horizontal |
| ) |
| .onSizeChanged { size -> |
| componentWidth = size.width.toFloat() |
| } |
| ) { |
| for (i in 0 until numSlots) { |
| val idx = i + currentIndex.value - initialSlotIndex |
| val visible = idx in 0 until provider.value.count() |
| ItemHolder(i, slotPrefix, showSlots) { |
| if (visible) { |
| if (provider.value.hasItemsWithProperties()) { |
| @Suppress("DEPRECATION") |
| val properties = motionProperties("$slotPrefix$i") |
| provider.value.getContent(idx, properties).invoke() |
| } else { |
| provider.value.getContent(idx).invoke() |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| @Composable |
| fun ItemHolder(i: Int, slotPrefix: String, showSlot: Boolean, function: @Composable () -> Unit) { |
| var modifier = Modifier |
| .layoutId("$slotPrefix$i") |
| |
| if (showSlot) { |
| modifier = modifier |
| .clip(RoundedCornerShape(20.dp)) |
| .border( |
| width = 2.dp, |
| color = Color(0, 0, 0, 60), |
| shape = RoundedCornerShape(20.dp) |
| ) |
| } |
| Box( |
| modifier = modifier, |
| contentAlignment = Alignment.Center |
| ) { |
| function.invoke() |
| } |
| } |
| |
| private enum class MotionCarouselDirection { |
| FORWARD, |
| BACKWARD |
| } |
| |
| private data class CarouselState( |
| var direction: MotionCarouselDirection, |
| var index: Int, |
| var targetIndex: Int, |
| var snapping: Boolean, |
| var animating: Boolean |
| ) |
| |
| inline fun <T> MotionCarouselScope.items( |
| items: List<T>, |
| crossinline itemContent: @Composable (item: T) -> Unit |
| ) = items(items.size) { index -> |
| itemContent(items[index]) |
| } |
| |
| interface MotionCarouselScope { |
| fun items( |
| count: Int, |
| itemContent: @Composable (index: Int) -> Unit |
| ) |
| |
| @OptIn(ExperimentalMotionApi::class) |
| @Suppress("UnavailableSymbol") |
| fun itemsWithProperties( |
| count: Int, |
| @Suppress("HiddenTypeParameter") |
| itemContent: @Composable ( |
| index: Int, |
| properties: androidx.compose.runtime.State<MotionProperties> |
| ) -> Unit |
| ) |
| } |
| |
| @OptIn(ExperimentalMotionApi::class) |
| @Suppress("UnavailableSymbol") |
| inline fun <T> MotionCarouselScope.itemsWithProperties( |
| items: List<T>, |
| @Suppress("HiddenTypeParameter") |
| crossinline itemContent: @Composable ( |
| item: T, |
| properties: androidx.compose.runtime.State<MotionProperties> |
| ) -> Unit |
| ) = itemsWithProperties(items.size) { index, properties -> |
| itemContent(items[index], properties) |
| } |
| |
| @Composable |
| private fun rememberStateOfItemsProvider( |
| content: MotionCarouselScope.() -> Unit |
| ): androidx.compose.runtime.State<MotionItemsProvider> { |
| val latestContent = rememberUpdatedState(content) |
| return remember { |
| derivedStateOf { MotionCarouselScopeImpl().apply(latestContent.value) } |
| } |
| } |
| |
| @OptIn(ExperimentalMotionApi::class) |
| interface MotionItemsProvider { |
| @SuppressWarnings("UnavailableSymbol") |
| fun getContent(index: Int): @Composable() () -> Unit |
| @SuppressWarnings("UnavailableSymbol") |
| fun getContent( |
| index: Int, |
| @SuppressWarnings("HiddenTypeParameter") |
| properties: androidx.compose.runtime.State<MotionProperties> |
| ): @Composable() () -> Unit |
| |
| fun count(): Int |
| fun hasItemsWithProperties(): Boolean |
| } |
| |
| @OptIn(ExperimentalMotionApi::class) |
| private class MotionCarouselScopeImpl() : MotionCarouselScope, MotionItemsProvider { |
| |
| var itemsCount = 0 |
| var itemsProvider: @Composable ((index: Int) -> Unit)? = null |
| var itemsProviderWithProperties: @Composable ((index: Int, |
| properties: androidx.compose.runtime.State<MotionProperties>) -> Unit)? = |
| null |
| |
| override fun items( |
| count: Int, |
| itemContent: @Composable (index: Int) -> Unit |
| ) { |
| itemsCount = count |
| itemsProvider = itemContent |
| } |
| |
| override fun itemsWithProperties( |
| count: Int, |
| itemContent: @Composable ( |
| index: Int, |
| properties: androidx.compose.runtime.State<MotionProperties> |
| ) -> Unit |
| ) { |
| itemsCount = count |
| itemsProviderWithProperties = itemContent |
| } |
| |
| override fun getContent(index: Int): @Composable () -> Unit { |
| return { |
| itemsProvider?.invoke(index) |
| } |
| } |
| |
| override fun getContent( |
| index: Int, |
| properties: androidx.compose.runtime.State<MotionProperties> |
| ): @Composable () -> Unit { |
| return { |
| itemsProviderWithProperties?.invoke(index, properties) |
| } |
| } |
| |
| override fun count(): Int { |
| return itemsCount |
| } |
| |
| override fun hasItemsWithProperties(): Boolean { |
| return itemsProviderWithProperties != null |
| } |
| } |