Skip to content

Instantly share code, notes, and snippets.

@EudyContreras
Last active November 7, 2023 15:00
Show Gist options
  • Save EudyContreras/f91b20c49552e02607816b3aea6e7f43 to your computer and use it in GitHub Desktop.
Save EudyContreras/f91b20c49552e02607816b3aea6e7f43 to your computer and use it in GitHub Desktop.
import androidx.compose.animation.*
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import java.util.*
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import kotlin.collections.HashSet
@Immutable
sealed class Item<T> {
class Unavailable<T>: Item<T>()
data class Available<T>(
val data: T,
private var onVisible: (suspend () -> Unit)?
): Item<T>() {
suspend fun notifyVisible() {
this.onVisible?.invoke()
this.onVisible = null
}
}
override fun toString(): String {
return when(this) {
is Unavailable -> "Unavailable"
is Available -> "Available (${data.toString()})"
}
}
}
@Immutable
data class ItemCollection<T>(
val entries: List<ItemState<T>>,
val id: String = UUID.randomUUID().toString()
)
@Stable
class ItemState<T>(
initialItem: Item<T>,
val id: String = UUID.randomUUID().toString()
) {
@Stable
val item: MutableState<Item<T>> = mutableStateOf(initialItem)
@Stable
val isVisible: MutableState<Boolean> = mutableStateOf(true)
override fun toString(): String {
return item.value.toString()
}
}
open class LazyAnimatedColumnAdapter<T>(
initialItems: List<T> = emptyList(),
val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default) + SupervisorJob(),
val isReversed: Boolean = false
) {
private val entries = LinkedList<ItemState<T>>().apply {
if (initialItems.isEmpty()) {
add(ItemState(initialItem = Item.Unavailable()))
} else {
addAll(initialItems.map {
ItemState(
initialItem = Item.Available(it, onVisible = null)
)
})
}
}
private val _items: MutableStateFlow<ItemCollection<T>> = MutableStateFlow(ItemCollection(entries))
private val updateQueue: BlockingQueue<T> = LinkedBlockingQueue()
private val removals: HashSet<ItemState<T>> = HashSet()
val items: StateFlow<ItemCollection<T>> = _items
init {
coroutineScope.launch {
val item = runCatching {
withContext(Dispatchers.Default) {
updateQueue.poll(Long.MAX_VALUE, TimeUnit.SECONDS)
}
}.getOrNull()
handleItem(item)
}
}
private suspend fun handleItem(item: T?) {
if (item == null) return
if (isReversed) {
val firstItem = entries.first
firstItem.item.value = Item.Available(item) {
entries.addFirst(ItemState(initialItem = Item.Unavailable()))
_items.tryEmit(ItemCollection(entries.toList()))
pollNextItem()
}
} else {
val lastItem = entries.last
lastItem.item.value = Item.Available(item) {
entries.add(ItemState(initialItem = Item.Unavailable()))
_items.tryEmit(ItemCollection(entries.toList()))
pollNextItem()
}
}
}
private suspend fun pollNextItem() {
coroutineScope.launch {
delay(MIN_UPDATE_INTERVAL)
val nextItem = runCatching {
withContext(Dispatchers.Default) {
updateQueue.poll(Long.MAX_VALUE, TimeUnit.SECONDS)
}
}.getOrNull()
handleItem(nextItem)
}
}
fun clear() {
entries.clear()
_items.tryEmit(ItemCollection(entries.toList()))
}
fun addItem(item: T) {
entries.removeAll(removals)
removals.clear()
updateQueue.add(item)
}
fun removeItem(index: Int) {
val entry = entries[index]
entry.isVisible.value = false
removals.add(entry)
}
companion object {
private const val MIN_UPDATE_INTERVAL = 100L
}
}
object AnimatedLazyColumnDefaults {
val DefaultHeader: LazyListScope.() -> Unit = {}
val DefaultFooter: LazyListScope.() -> Unit = {}
}
@Composable
fun <T> AnimatedLazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
adapter: LazyAnimatedColumnAdapter<T>,
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = if (!adapter.isReversed) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
itemAddTransition: EnterTransition = fadeIn() + expandVertically(expandFrom = Alignment.CenterVertically) { 0 },
itemRemoveTransition: ExitTransition = fadeOut() + shrinkOut(shrinkTowards = Alignment.Center) { IntSize.Zero },
header: LazyListScope.() -> Unit = AnimatedLazyColumnDefaults.DefaultHeader,
footer: LazyListScope.() -> Unit = AnimatedLazyColumnDefaults.DefaultFooter,
itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
) {
val collection by adapter.items.collectAsState()
LazyColumn(
modifier = modifier,
state = state,
reverseLayout = adapter.isReversed,
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
flingBehavior = flingBehavior,
userScrollEnabled = userScrollEnabled,
) {
header(this)
for ((index, entry) in collection.entries.withIndex()) {
item(entry.id) {
AnimatedItem(
item = entry,
enterTransition = itemAddTransition,
exitTransition = itemRemoveTransition
) { data ->
itemContent(index, data)
}
}
}
footer(this)
}
}
@Composable
private fun <T> AnimatedItem(
item: ItemState<T>,
enterTransition: EnterTransition,
exitTransition: ExitTransition,
content: @Composable (item: T) -> Unit
) {
val itemValue by item.item
AnimatedVisibility(
visible = item.isVisible.value,
exit = exitTransition
) {
AnimatedVisibility(
visible = itemValue is Item.Available,
enter = enterTransition,
exit = fadeOut()
) {
when (itemValue) {
is Item.Available<T> -> AvailableItem(itemValue as Item.Available<T>, content)
is Item.Unavailable -> Spacer(modifier = Modifier.fillMaxWidth())
}
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun <T> AnimatedVisibilityScope.AvailableItem(
item: Item.Available<T>,
content: @Composable (item: T) -> Unit
) {
LaunchedEffect(Unit) {
snapshotFlow { this@AvailableItem.transition.currentState }.collectLatest {
if (it == EnterExitState.Visible) {
item.notifyVisible()
}
}
}
content(item.data)
}
class ExampleViewModel: ViewModel() {
val adapter: LazyAnimatedColumnAdapter<String> = LazyAnimatedColumnAdapter(emptyList(), isReversed = true)
var counter: Int = 0
fun addItem() {
adapter.addItem("Item :$counter")
counter ++
}
fun removeItem(index: Int) {
adapter.removeItem(index)
}
}
@Composable
fun AnimatedLazyColumnExample() {
val viewModel: ExampleViewModel = viewModel()
Column(
modifier = Modifier.fillMaxSize()
) {
Box(modifier = Modifier.weight(1f)) {
val listState = rememberLazyListState()
AnimatedLazyColumn(
adapter = viewModel.adapter,
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 24.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) { data ->
Column(
modifier = Modifier
.padding(vertical = 6.dp, horizontal = 12.dp)
.fillMaxWidth()
.background(Color.Red)
.padding(vertical = 20.dp)
,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
){
Text(text = data, fontSize = 16.sp)
}
}
}
Button(onClick = { viewModel.addItem()}) {
Text(text = "Add item")
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment