Skip to content

Instantly share code, notes, and snippets.

@darvld
Last active April 24, 2022 19:01
Show Gist options
  • Save darvld/bb23aa418c7c1908c7dea9a72094505e to your computer and use it in GitHub Desktop.
Save darvld/bb23aa418c7c1908c7dea9a72094505e to your computer and use it in GitHub Desktop.
This gist contains a simple implementation of animated item transitions for use with `LazyListScope` (`LazyColumn`/`LazyRow`).
package io.github.darvld.utils
import androidx.compose.animation.*
import androidx.compose.animation.core.ExperimentalTransitionApi
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.*
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DiffUtil.DiffResult
import androidx.recyclerview.widget.ListUpdateCallback
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**A state container used for tracking changes in content lists.
*
* Use [rememberAnimatedListState] to create an instance of this class.*/
class AnimatedListState<T> internal constructor() {
private val mutex = Mutex()
private val _items = mutableStateListOf<AnimatedItem>()
@PublishedApi
internal val items: List<AnimatedItem>
get() = _items
internal val data: List<T>
get() = _items.map { it.data }
/**Wrapper class for the list data, used to hold the state of the content animation.*/
@PublishedApi
internal inner class AnimatedItem(
val visibility: MutableTransitionState<Boolean>,
val data: T
) {
constructor(data: T) : this(
visibility = MutableTransitionState(false).apply { targetState = true },
data,
)
// Declare these so we can use destructuring later
operator fun component1(): MutableTransitionState<Boolean> = visibility
operator fun component2(): T = data
@OptIn(ExperimentalTransitionApi::class)
inline val stale: Boolean
get() = visibility.isIdle && !visibility.targetState
}
internal suspend fun pruneItems() {
// Remove all stale items, but use the lock so we don't cause any race conditions
// with diff calls
mutex.withLock { _items.removeAll { it.stale } }
}
internal suspend fun applyDiff(
oldItems: List<T>,
newItems: List<T>,
keySelector: ((T) -> Any)?,
compareItems: ((T, T) -> Boolean)?,
detectMoves: Boolean = false,
) {
val callback = createDiffCallback(
oldItems,
newItems,
keySelector,
// Fall back to Any.equals if no custom comparison is specified
compareItems = compareItems ?: { a, b -> a == b },
)
val result = withContext(Dispatchers.Unconfined) {
DiffUtil.calculateDiff(callback, detectMoves)
}
// Dispatch diff updates using the lock to avoid concurrency issues
mutex.withLock {
result.dispatchUpdatesTo(createUpdateCallback(newItems))
}
}
private inline fun createDiffCallback(
oldItems: List<T>,
newItems: List<T>,
noinline keySelector: ((T) -> Any)?,
crossinline compareItems: (T, T) -> Boolean,
): DiffUtil.Callback {
return object : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldItems.size
override fun getNewListSize(): Int = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
// When no key selector is provided, fallback to referencial equality
return if (keySelector != null) {
keySelector(oldItems[oldItemPosition]) == keySelector(newItems[newItemPosition])
} else {
oldItems[oldItemPosition] === newItems[newItemPosition]
}
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return compareItems(oldItems[oldItemPosition], newItems[newItemPosition])
}
}
}
private fun createUpdateCallback(newItems: List<T>): ListUpdateCallback {
return object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
for (i in 0 until count) {
_items.add(
index = position + i,
element = AnimatedItem(data = newItems[position + i])
)
}
}
override fun onRemoved(position: Int, count: Int) {
for (i in 0 until count) {
_items[position + i].visibility.targetState = false
}
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
onRemoved(fromPosition, 1)
onInserted(toPosition, 1)
}
// Automatically handled by Compose
override fun onChanged(position: Int, count: Int, payload: Any?) = Unit
}
}
}
/**Creates and remembers a new [AnimatedListState], automatically observing changes to the provided
* [items] and applying the difference to the state holder.
*
* Pass the resulting state to a [LazyListScope.animatedItems] call to automatically animate
* added/removed items.
*
* @param items A list to be observed by the animated state.
* @param key A selector used for identity comparison. If null, the referential equality operator
* (===) is used.
* @param compareItems Function used to compare elements during the diff process. Defaults to
* [Any.equals] operator.
* @param detectMoves Whether to search for position changes when computing the diff.*/
@Composable
fun <T : Any> rememberAnimatedListState(
items: List<T>,
key: ((T) -> Any)? = null,
compareItems: ((T, T) -> Boolean)? = null,
detectMoves: Boolean = false,
): AnimatedListState<T> {
val state = remember { AnimatedListState<T>() }
val updatedItems = rememberUpdatedState(items)
// Subscribe to the incoming items
val itemsFlow = remember {
snapshotFlow { updatedItems.value.toList() }
}
// Emit a new pulse when stale items are detected
val disposalFlow = remember {
snapshotFlow {
state.items.any { it.stale }
}.mapNotNull {
if (it) Unit else null
}
}
// Observe incoming changes and apply diff
LaunchedEffect(state) {
itemsFlow.collect { new ->
state.applyDiff(state.data, new, key, compareItems, detectMoves)
}
}
// Remove stale items
LaunchedEffect(state) {
disposalFlow.collect { state.pruneItems() }
}
return state
}
/**Similar to [LazyListScope.items], but automatically animates added/removed items with the
* specified [enter] and [exit] transitions.
*
* @see rememberAnimatedListState*/
@ExperimentalAnimationApi
inline fun <T> LazyListScope.animatedItems(
state: AnimatedListState<T>,
enter: EnterTransition = fadeIn(),
exit: ExitTransition = fadeOut(),
crossinline content: @Composable (T) -> Unit,
) {
items(state.items) { (visibility, item) ->
AnimatedVisibility(
visibleState = visibility,
enter = enter,
exit = exit,
) {
content(item)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment