Skip to content

Instantly share code, notes, and snippets.

@raghunandankavi2010
Created July 30, 2022 14:03
Show Gist options
  • Save raghunandankavi2010/419ebac4ff7dd111697b86308ea0b20e to your computer and use it in GitHub Desktop.
Save raghunandankavi2010/419ebac4ff7dd111697b86308ea0b20e to your computer and use it in GitHub Desktop.
Custom layout
package com.example.composelearning.lists
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FloatSpringSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.example.composelearning.ui.theme.ComposeLearningTheme
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Stable
interface CircularRowState {
val horizontalOffset: Float
val firstVisibleItem: Int
val lastVisibleItem: Int
val scaleX: Float
val scaleY: Float
val alphaValue: Float
suspend fun snapTo(value: Float)
suspend fun decayTo(velocity: Float, value: Float)
suspend fun stop()
fun offsetFor(index: Int): IntOffset
fun setup(config: CircularRowConfig)
fun alpha(i: Int): Float
fun scale(i: Int): Float
}
data class CircularRowConfig(
val contentWidth: Float = 0f,
val numItems: Int = 0,
val visibleItems: Int = 0,
val overshootItems: Int = 0,
val itemWidth: Int = 0,
)
class CircularRowStateImpl(
currentOffset: Float = 0f,
) : CircularRowState {
private val animatable = Animatable(currentOffset)
private var itemWidth = 0f
private var config = CircularRowConfig()
private var initialOffset = 0f
private val decayAnimationSpec = FloatSpringSpec(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow,
)
override val alphaValue: Float
get() = (1 - (abs(horizontalOffset) / (config.contentWidth / 2))).coerceIn(0f, 1f)
override val scaleX: Float
get() = horizontalOffset
override val scaleY: Float
get() = horizontalOffset
private val minOffset: Float
get() = -(config.numItems - 1) * itemWidth
override val horizontalOffset: Float
get() = animatable.value
override val firstVisibleItem: Int
get() = ((-horizontalOffset - initialOffset) / itemWidth).toInt().coerceAtLeast(0)
override val lastVisibleItem: Int
get() = (((-horizontalOffset - initialOffset) / itemWidth).toInt() + config.visibleItems)
.coerceAtMost(config.numItems - 1)
override suspend fun snapTo(value: Float) {
val minOvershoot = -(config.numItems - 1 + config.overshootItems) * itemWidth
val maxOvershoot = config.overshootItems * itemWidth
animatable.snapTo(value.coerceIn(minOvershoot, maxOvershoot))
}
override suspend fun decayTo(velocity: Float, value: Float) {
val constrainedValue = value.coerceIn(minOffset, 0f).absoluteValue
val remainder = (constrainedValue / itemWidth) - (constrainedValue / itemWidth).toInt()
val extra = if (remainder <= 0.5f) 0 else 1
val target = ((constrainedValue / itemWidth).toInt() + extra) * itemWidth
animatable.animateTo(
targetValue = -target,
initialVelocity = velocity,
animationSpec = decayAnimationSpec,
)
}
override suspend fun stop() {
animatable.stop()
}
override fun setup(config: CircularRowConfig) {
this.config = config
itemWidth = config.contentWidth / config.visibleItems
initialOffset = (config.contentWidth - config.itemWidth) / 2f
}
override fun alpha(i: Int): Float {
val maxOffset = config.contentWidth / 2f
val x = (horizontalOffset + initialOffset + i * itemWidth)
val deltaFromCenter = (x - initialOffset)
val percentFromCenter = 1.0f - abs(deltaFromCenter) / maxOffset
return 0.25f + (percentFromCenter * 0.75f)
}
override fun scale(i: Int): Float {
val maxOffset = config.contentWidth / 2f
val x = (horizontalOffset + initialOffset + i * itemWidth)
val deltaFromCenter = (x - initialOffset)
val percentFromCenter = 1.0f - abs(deltaFromCenter) / maxOffset
return 0.5f + (percentFromCenter * 0.5f)//1f - (1f - 0.65f) * (deltaFromCenter / maxOffset).absoluteValue
}
override fun offsetFor(index: Int): IntOffset {
val x = (horizontalOffset + initialOffset + (index * (itemWidth)))
val y = 0
return IntOffset(
x = x.roundToInt(),
y = y
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CircularRowStateImpl
if (animatable.value != other.animatable.value) return false
if (itemWidth != other.itemWidth) return false
if (config != other.config) return false
if (initialOffset != other.initialOffset) return false
if (decayAnimationSpec != other.decayAnimationSpec) return false
return true
}
override fun hashCode(): Int {
var result = animatable.value.hashCode()
result = 31 * result + itemWidth.hashCode()
result = 31 * result + config.hashCode()
result = 31 * result + initialOffset.hashCode()
result = 31 * result + decayAnimationSpec.hashCode()
return result
}
companion object {
val Saver = Saver<CircularRowStateImpl, List<Any>>(
save = { listOf(it.horizontalOffset) },
restore = {
CircularRowStateImpl(it[0] as Float)
}
)
}
}
@Composable
fun RowItem(
color: Color,
) {
Box(modifier = Modifier
.size(55.dp)
.clip(shape = CircleShape)
.background(color))
// Image(
// painter = painterResource(id = com.example.composelearning.R.drawable.ic_launcher_background),
// contentDescription = null,
// modifier = Modifier
// .size(50.dp)
// .clip(shape = CircleShape),
// contentScale = ContentScale.Crop
// )
}
private fun Modifier.drag(
state: CircularRowState,
) = pointerInput(Unit) {
val decay = splineBasedDecay<Float>(this)
coroutineScope {
while (true) {
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
state.stop()
val tracker = VelocityTracker()
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
val horizontalDragOffset = state.horizontalOffset + change.positionChange().x
launch {
state.snapTo(horizontalDragOffset)
}
tracker.addPosition(change.uptimeMillis, change.position)
change.consumePositionChange()
}
}
val velocity = tracker.calculateVelocity().x
val targetValue = decay.calculateTargetValue(state.horizontalOffset, velocity)
launch {
state.decayTo(velocity, targetValue)
}
}
}
}
@Composable
fun CircularList(
itemWidthDp: Dp,
visibleItems: Int,
modifier: Modifier = Modifier,
state: CircularRowState = rememberCircularRowState(),
overshootItems: Int = 3,
content: @Composable () -> Unit,
) {
check(visibleItems > 0) { "Visible items must be positive" }
val itemWidth = with(LocalDensity.current) { itemWidthDp.toPx() }
Layout(
modifier = modifier
.clipToBounds()
.drag(state),
content = content,
) { measurables, constraints ->
val itemConstraints =
Constraints.fixed(width = itemWidth.roundToInt(), height = constraints.maxHeight)
val placeables = measurables.map { measurable -> measurable.measure(itemConstraints) }
state.setup(
CircularRowConfig(
contentWidth = constraints.maxWidth.toFloat(),
numItems = placeables.size,
visibleItems = visibleItems,
overshootItems = overshootItems,
itemWidth = itemWidth.toInt()
)
)
layout(
width = constraints.maxWidth,
height = constraints.maxHeight,
) {
for (i in state.firstVisibleItem..state.lastVisibleItem) {
placeables[i].placeRelativeWithLayer(state.offsetFor(i), layerBlock = {
alpha = state.alpha(i)
scaleX = state.scale(i)
scaleY = state.scale(i)
})
}
}
}
}
@Composable
fun rememberCircularRowState(): CircularRowState {
val state = rememberSaveable(saver = CircularRowStateImpl.Saver) {
CircularRowStateImpl()
}
return state
}
private val colors = listOf(
Color.Red,
Color.Green,
Color.Blue,
Color.Magenta,
Color.Yellow,
Color.Cyan,
)
@Preview(showBackground = true)
@Composable
fun PreviewCircularList() {
ComposeLearningTheme {
Surface {
CircularList(
itemWidthDp = 50.dp,
visibleItems = 5,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.background(Color.Black),
) {
for (i in 0 until 40) {
RowItem(
color = colors[i % colors.size],
)
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment