https://kotlinlang.org logo
Title
a

Alexander Black

08/07/2021, 2:39 PM
So I absolutely love this widget from the Google Podcast app, and I’m wondering if anyone has any advice on how I could go about implementing such a widget in Compose? I might be a bit out of my depth on it, but if anyone has any advice or places to start I’d greatly appreciate it.
a

Alexander Black

08/07/2021, 8:03 PM
@Halil Ozercan this is awesome!!!! Thank you very much for sending me this. Looks super close to what I’m trying to do
:party-parrot: 1
t

Tin Tran

08/09/2021, 4:53 AM
Hi Halil. I don’t quite understand how you calculate the scale in the
WeightNumbersRow
. Could you explain like I’m 5 😅
h

Halil Ozercan

08/09/2021, 8:40 AM
@Tin Tran oh man that's a weird equation for sure. First of all, it's not based on anything concrete. I simply wanted to have inverse quadratic relation between some numbers 😄 let's unpack
((2f - abs(number - currentValue).coerceAtMost(2f)).pow(2f) + 2f) / 6f
1.
abs(number-currentValue)
Number is which "number" we are rendering, "currentValue" is the currently selected value, it comes from state. We first find the absolute distance between them because there is left-right symmetry. 2.
distance.coerceAtMost(2f)
Distances that are longer than 2f are ignored and snapped to 2f. This is because we only show 2 numbers to the left and right from currently selected value. The other numbers can have the same scale if they come into view. 3.
2f - limitedDistance
Simply inverse the distance. Closer "numbers" to currentValue will have higher value, farther "numbers" will have lower value. 4.
inversedValue.pow(2f)
Make it quadratic, meaning that we want parabolic change instead of linear. 5.
parabolicValue + 2f
Parabolic value is in the range of
[0, 4]
. 4 is fine but 0 is hard to work with. You cannot multiply anything with 0. Our base should be higher than 0 to make sense. 6.
baseFixedValue / 6f
Finally we divide our range to the maximum value ->
[0.333f, 1f]
Our new scale is between 0.333 and 1. Farthest numbers are going to be scaled down to 3rd of their size, while dead center item will have unchanged scale. Hope this clears everything 😄
ELI5: Simply map the absolute difference between "number" and "currentValue" to a range of
[0.333f, 1f]
.
t

Tin Tran

08/09/2021, 8:45 AM
Thanks! This definitely clear things up a bit for me. I’ll try to modify them a bit to fit my requirement and add fling behavior as well. Great job Halil!
a

Alexander Black

08/09/2021, 1:37 PM
@Tin Tran if you figure out fling behavior will you send a snippet my way? I was attempting the same thing yesterday, but was not successful.
t

Tin Tran

08/09/2021, 2:01 PM
I got it working with number slider with this
modifier.pointerInput(Unit) {
            val decay = splineBasedDecay<Float>(this)
            val velocityTracker = VelocityTracker()
            detectHorizontalDragGestures(
                onDragEnd = {
                    val velocity = velocityTracker.calculateVelocity().x
                    val target = decay.calculateTargetValue(state.value, velocity) / size.width * -3
                    state.roundToValue(state.value + target)
                },
            ) { change, dragAmount ->
                val scrolledValue = (dragAmount / size.width) * 3
                state.changeValueBy(scrolledValue)
                velocityTracker.addPosition(
                    change.uptimeMillis,
                    change.position
                )
                change.consumePositionChange()
            }

fun roundToValue(target: Float) {
        coroutineScope.launch {
            var boundedTarget = target
            if (target <= valueRange.start) {
                boundedTarget = valueRange.start
            } else if (target >= valueRange.endInclusive) {
                boundedTarget = valueRange.endInclusive
            }
            animatedValue.animateTo(boundedTarget.roundToInt().toFloat(), tween(500))
        }
    }
It’s not quite what I wanted yet. I want it to slowdown more smoothly like how a scrollable Row does. But this is usable 😅
I tried
animateDecay
but it didn’t seems to work. The value is not updated so I have to use this ‘hack’ instead 😄
a

Alexander Black

08/09/2021, 3:13 PM
@Tin Tran Had the same issue… pretty sure it’s velocityTracker… if you log the output of it most of the time it’s zero. I’m not entirely sure why, but pretty sure that’s the root issue for me at least.
@Halil Ozercan fantastic!! I’m going to play around with it… thanks for sharing. 🙌
t

Tin Tran

08/09/2021, 3:15 PM
For me it’s work as the velocityTracker works as intended just that the animteDecay won’t update the value for me. The code I posted is using velocityTracker as you can see
a

Alexander Black

08/09/2021, 3:16 PM
@Tin Tran Let me share my code… maybe it will help you out… if you are getting the velocity to work.
👀 1
t

Tin Tran

08/09/2021, 3:18 PM
Ok
a

Alexander Black

08/09/2021, 3:18 PM
I simply added a fling  flingBehavior method to the WeightEntryState class
class WeightEntryState(
    initialValue: Float,
    var valueRange: ClosedFloatingPointRange<Float>,
    private val coroutineScope: CoroutineScope
) {
    private val animatedValue = Animatable(initialValue)

    val value: Float
        get() = animatedValue.value


    fun changeValueBy(number: Float) {
        animatedValue.updateBounds(valueRange.start, valueRange.endInclusive)
        coroutineScope.launch {
            animatedValue.snapTo((animatedValue.value - number).coerceIn(valueRange))
        }
    }

    fun flingBehavior(velocity: Float, decayRate: DecayAnimationSpec<Float>){
        coroutineScope.launch {
            animatedValue.animateDecay(velocity, decayRate)
        }
    }

    fun snapToValue(newValue: Float) {
        coroutineScope.launch {
            animatedValue.snapTo(newValue)
        }
    }
}
then set my animated view bounds in the ChangeValueBY
which makes it so you don’t go out of bounds with your animation
t

Tin Tran

08/09/2021, 3:19 PM
Didn’t know that before I’ll give it a try.
a

Alexander Black

08/09/2021, 3:19 PM
And I define these
val velocity by remember { mutableStateOf(VelocityTracker()) }
        val animationDecay: DecayAnimationSpec<Float> = rememberSplineBasedDecay()
and call it like this:
WeightIndicatorsRow(
    state = state,
    modifier = Modifier
        .background(MyAppTheme.colors.uiBackground)
        .pointerInput(Unit) {
            detectHorizontalDragGestures(onDragStart = {
                velocity.resetTracking()
            }, onDragEnd = {

                Log.d(LOG_TAG, "velocity x: ${velocity.calculateVelocity().x}")
                state.flingBehavior(
                    velocity = velocity.calculateVelocity().x,
                    decayRate = animationDecay
                )
            },
                onHorizontalDrag = { change, dragAmount ->
                    val scrolledValue = (dragAmount / size.width) * 80
                    state.changeValueBy(scrolledValue)
                    velocity.addPosition(change.uptimeMillis, Offset(x = dragAmount, 0f))
                }
            )
        })
I would not update your calling code, because your’s is working
mine is crap. 😂
but just so you understand how I’m setting everything up
hope that helps ya out
I’m going to play with your code a bit and see if I can improve mine
🙌 1
t

Tin Tran

08/09/2021, 4:02 PM
a

Alexander Black

08/09/2021, 4:14 PM
That’s pretty much to the level that I got to… It’s like super close… But the velocity just decays too fast for what I’m trying to do.
but I like how your component looks… That’s super nice.
h

Halil Ozercan

08/09/2021, 4:15 PM
Let me take a shot at this slow decay as well 😄
a

Alexander Black

08/09/2021, 4:15 PM
👍 🙌 please do!
I’m a total noob to compose and the whole animation stack, so hopefully you can see something I’m not.
h

Halil Ozercan

08/09/2021, 4:43 PM
@Alexander Black is this what you are kinda looking for? I purposefully made it a lot slower than it needs to be 😄
🔥 1
🙌 1
🎉 1
@Tin Tran your code is very close, it just needs a exponential decay and
animateDecay
calls
WeightNumbersRow(
    state = state,
    modifier = Modifier.pointerInput(Unit) {
        detectHorizontalDragGestures(
            onDragEnd = {
                state.onDragEnd()
            }
        ) { change, dragAmount ->
            val scrolledValue = (dragAmount / size.width) * 5
            state.changeValueBy(scrolledValue)
            state.onDrag(change.uptimeMillis, - (change.position.x / size.width) * 5)
            change.consumePositionChange()
        }
    }
)
// these goes into WeightEntryState

private val decay = exponentialDecay<Float>(
    frictionMultiplier = 0.4f,
    absVelocityThreshold = 0.05f
)
private val velocityTracker = VelocityTracker()
fun onDrag(durationMillis: Long, position: Float) {
    velocityTracker.addPosition(
        durationMillis,
        Offset(position, 0f)
    )
}

fun onDragEnd() {
    val velocity = velocityTracker.calculateVelocity().x
    coroutineScope.launch {
        animatedValue.animateDecay(velocity, decay) {}
        roundValue()
    }
}
t

Tin Tran

08/09/2021, 4:50 PM
private val decay = exponentialDecay<Float>(
    frictionMultiplier = 0.4f,
    absVelocityThreshold = 0.05f
)
I tried this but can’t figure out what the velocity threshold should be. Thanks a lot 🙌
a

Alexander Black

08/09/2021, 4:59 PM
@Halil Ozercan exactly what I was trying to do!!! You are my hero!!! 🙌🤩😂
Looks freaking amazing!!! Thank you!!
h

Halil Ozercan

08/09/2021, 5:10 PM
Lol, glad if I could be any help. @Tin Tran figuring out velocity threshold and friction multiplier is all about test-and-iterate. I also wanted to find a hack for that last bit of rounding down or up. Instead of suddenly jumping to a value, find an initial velocity so that decay animation always ends on an integer. I'm going to take a look at it later tho.
🎉 1
t

Tin Tran

08/09/2021, 5:16 PM
That sounds dope :kotlin-intensifies:
a

Alexander Black

08/09/2021, 5:16 PM
Fantastic!! Thank you so much for putting in time to figure this out. Definitely a good leaning experience for me. And keep us posted if you add more functionality to this. 🤩
@Halil Ozercan Cannot thank you enough… got mine working… looks freaking great… Probably still going to play around with the frictionMultiplier, but generally works exactly how I wanted.
🎉 1
I even got the snapping to work nicely