Skip to content

Commit

Permalink
Try and make iOS fling more 'native' (#1377)
Browse files Browse the repository at this point in the history
At the moment Compose MP using the same spline decay
animation that Android uses, which isn't great.
This PR changes this by using our own fling behavior
throughout the app.

This allows us to tweak the decay animation to better match
iOS. It isn't perfect, but it's a lot better than default.
  • Loading branch information
chrisbanes committed Jul 8, 2023
1 parent 7408224 commit 84a7f5d
Show file tree
Hide file tree
Showing 11 changed files with 153 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2023, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.common.compose

import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.runtime.Composable

@Composable
actual fun decayAnimationSpec(): DecayAnimationSpec<Float> = rememberSplineBasedDecay()
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ fun <E : Entry> EntryGrid(
PaddingValues(horizontal = bodyMargin, vertical = gutter),
horizontalArrangement = Arrangement.spacedBy(gutter),
verticalArrangement = Arrangement.spacedBy(gutter),
flingBehavior = rememberTiviFlingBehavior(),
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.bodyWidth()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2021, Google LLC, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.common.compose

import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.abs
import kotlinx.coroutines.withContext

@Composable
fun rememberTiviFlingBehavior(): FlingBehavior {
val decayAnimationSpec = decayAnimationSpec()
return remember(decayAnimationSpec) {
DefaultFlingBehavior(decayAnimationSpec)
}
}

@ExperimentalFoundationApi
@Composable
fun rememberTiviSnapFlingBehavior(
snapLayoutInfoProvider: SnapLayoutInfoProvider,
): SnapFlingBehavior {
val density = LocalDensity.current
val highVelocityApproachSpec: DecayAnimationSpec<Float> = rememberTiviDecayAnimationSpec()
return remember(snapLayoutInfoProvider, highVelocityApproachSpec, density) {
SnapFlingBehavior(
snapLayoutInfoProvider = snapLayoutInfoProvider,
lowVelocityAnimationSpec = tween(easing = LinearEasing),
highVelocityAnimationSpec = highVelocityApproachSpec,
snapAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow),
density = density,
)
}
}

@Composable
fun rememberTiviDecayAnimationSpec(): DecayAnimationSpec<Float> {
val spec = decayAnimationSpec()
return remember { spec }
}

@Composable
internal expect fun decayAnimationSpec(): DecayAnimationSpec<Float>

internal val DefaultScrollMotionDurationScale = object : MotionDurationScale {
override val scaleFactor: Float get() = 1f
}

internal class DefaultFlingBehavior(
private val flingDecay: DecayAnimationSpec<Float>,
private val motionDurationScale: MotionDurationScale = DefaultScrollMotionDurationScale,
) : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
// come up with the better threshold, but we need it since spline curve gives us NaNs
return withContext(motionDurationScale) {
if (abs(initialVelocity) > 1f) {
var velocityLeft = initialVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = initialVelocity,
).animateDecay(flingDecay) {
val delta = value - lastValue
val consumed = scrollBy(delta)
lastValue = value
velocityLeft = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) cancelAnimation()
}
velocityLeft
} else {
initialVelocity
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2023, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.common.compose

import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember

@Composable
actual fun decayAnimationSpec(): DecayAnimationSpec<Float> {
return remember { exponentialDecay(frictionMultiplier = 0.85f) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2023, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.common.compose

import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.runtime.Composable

@Composable
actual fun decayAnimationSpec(): DecayAnimationSpec<Float> = rememberSplineBasedDecay()
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package app.tivi.home.discover
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -57,6 +56,8 @@ import app.tivi.common.compose.LocalTiviTextCreator
import app.tivi.common.compose.ReportDrawnWhen
import app.tivi.common.compose.bodyWidth
import app.tivi.common.compose.rememberCoroutineScope
import app.tivi.common.compose.rememberTiviFlingBehavior
import app.tivi.common.compose.rememberTiviSnapFlingBehavior
import app.tivi.common.compose.ui.AutoSizedCircularProgressIndicator
import app.tivi.common.compose.ui.PosterCard
import app.tivi.common.compose.ui.TiviStandardAppBar
Expand Down Expand Up @@ -191,6 +192,7 @@ internal fun Discover(
Box(modifier = Modifier.pullRefresh(state = refreshState)) {
LazyColumn(
contentPadding = paddingValues,
flingBehavior = rememberTiviFlingBehavior(),
modifier = Modifier.bodyWidth(),
) {
item {
Expand Down Expand Up @@ -380,7 +382,7 @@ private fun <T : EntryWithShow<*>> EntryShowCarousel(
LazyRow(
state = lazyListState,
modifier = modifier,
flingBehavior = rememberSnapFlingBehavior(
flingBehavior = rememberTiviSnapFlingBehavior(
snapLayoutInfoProvider = remember(lazyListState) {
SnapLayoutInfoProvider(
lazyListState = lazyListState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import app.tivi.common.compose.fullSpanItem
import app.tivi.common.compose.items
import app.tivi.common.compose.rememberCoroutineScope
import app.tivi.common.compose.rememberLazyGridState
import app.tivi.common.compose.rememberTiviFlingBehavior
import app.tivi.common.compose.ui.EmptyContent
import app.tivi.common.compose.ui.PosterCard
import app.tivi.common.compose.ui.SearchTextField
Expand Down Expand Up @@ -253,6 +254,7 @@ private fun LibraryGrid(
LazyVerticalGrid(
state = rememberLazyGridState(lazyPagingItems.itemCount == 0),
columns = GridCells.Fixed(columns / 4),
flingBehavior = rememberTiviFlingBehavior(),
contentPadding = paddingValues + PaddingValues(
horizontal = (bodyMargin - 8.dp).coerceAtLeast(0.dp),
vertical = (gutter - 8.dp).coerceAtLeast(0.dp),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.tivi.common.compose.Layout
import app.tivi.common.compose.bodyWidth
import app.tivi.common.compose.rememberTiviFlingBehavior
import app.tivi.common.compose.ui.EmptyContent
import app.tivi.common.compose.ui.PosterCard
import app.tivi.common.compose.ui.SearchTextField
Expand Down Expand Up @@ -200,6 +201,7 @@ private fun SearchList(
contentPadding = contentPadding,
verticalArrangement = arrangement,
horizontalArrangement = arrangement,
flingBehavior = rememberTiviFlingBehavior(),
modifier = modifier,
) {
items(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -88,6 +87,8 @@ import app.tivi.common.compose.LocalTiviTextCreator
import app.tivi.common.compose.bodyWidth
import app.tivi.common.compose.gutterSpacer
import app.tivi.common.compose.itemSpacer
import app.tivi.common.compose.rememberTiviFlingBehavior
import app.tivi.common.compose.rememberTiviSnapFlingBehavior
import app.tivi.common.compose.ui.AsyncImage
import app.tivi.common.compose.ui.Backdrop
import app.tivi.common.compose.ui.ExpandingText
Expand Down Expand Up @@ -287,6 +288,7 @@ private fun ShowDetailsScrollingContent(
state = listState,
contentPadding = contentPadding,
modifier = modifier,
flingBehavior = rememberTiviFlingBehavior(),
) {
item {
Backdrop(
Expand Down Expand Up @@ -635,7 +637,7 @@ private fun RelatedShows(
LazyRow(
state = lazyListState,
modifier = modifier,
flingBehavior = rememberSnapFlingBehavior(
flingBehavior = rememberTiviSnapFlingBehavior(
snapLayoutInfoProvider = remember(lazyListState) {
SnapLayoutInfoProvider(
lazyListState = lazyListState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.DismissValue
Expand Down Expand Up @@ -65,6 +66,8 @@ import app.tivi.common.compose.Layout
import app.tivi.common.compose.LocalTiviTextCreator
import app.tivi.common.compose.bodyWidth
import app.tivi.common.compose.rememberCoroutineScope
import app.tivi.common.compose.rememberTiviDecayAnimationSpec
import app.tivi.common.compose.rememberTiviFlingBehavior
import app.tivi.common.compose.ui.RefreshButton
import app.tivi.common.compose.ui.TopAppBarWithBottomContent
import app.tivi.common.ui.resources.MR
Expand Down Expand Up @@ -270,6 +273,10 @@ private fun SeasonsPager(
HorizontalPager(
pageCount = seasons.size,
state = pagerState,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
highVelocityAnimationSpec = rememberTiviDecayAnimationSpec(),
),
modifier = modifier,
) { page ->
EpisodesList(
Expand All @@ -286,7 +293,10 @@ private fun EpisodesList(
onEpisodeClick: (episodeId: Long) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
LazyColumn(
modifier = modifier,
flingBehavior = rememberTiviFlingBehavior(),
) {
items(episodes, key = { it.episode.id }) { item ->
EpisodeWithWatchesRow(
episode = item.episode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import app.tivi.common.compose.fullSpanItem
import app.tivi.common.compose.items
import app.tivi.common.compose.rememberCoroutineScope
import app.tivi.common.compose.rememberLazyGridState
import app.tivi.common.compose.rememberTiviFlingBehavior
import app.tivi.common.compose.ui.AsyncImage
import app.tivi.common.compose.ui.EmptyContent
import app.tivi.common.compose.ui.SortChip
Expand Down Expand Up @@ -223,6 +224,7 @@ internal fun UpNext(
// We minus 8.dp off the grid padding, as we use content padding on the items below
horizontalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)),
verticalArrangement = Arrangement.spacedBy((gutter - 8.dp).coerceAtLeast(0.dp)),
flingBehavior = rememberTiviFlingBehavior(),
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.bodyWidth()
Expand Down

0 comments on commit 84a7f5d

Please sign in to comment.