-
-
Save belinwu/cd8422617f17691f8a5c555b54383ad6 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`).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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