Skip to content

Instantly share code, notes, and snippets.

@raghunandankavi2010
Created July 25, 2022 05:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save raghunandankavi2010/133b9a821b483cea584d5c7de658ba08 to your computer and use it in GitHub Desktop.
Save raghunandankavi2010/133b9a821b483cea584d5c7de658ba08 to your computer and use it in GitHub Desktop.
Circular Row List with scale and alpha using custom Layout
@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) / 2f - (itemWidth / 2f - 25f)
}
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
println("Percentage =$deltaFromCenter ${percentFromCenter}")
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(50.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().y
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 = 50.dp.toPx().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 PreviewCircularList5() {
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