From 9ec28707f7db723a920076d6639c9ddc076fd967 Mon Sep 17 00:00:00 2001 From: Abe White Date: Mon, 9 Sep 2024 23:58:19 -0500 Subject: [PATCH] Reduce excessive animating of list items. List items should now behave more similarly to iOS, only animating within animation blocks --- Sources/SkipUI/SkipUI/Containers/List.swift | 27 +++++++++++++++++--- Sources/SkipUI/SkipUI/Containers/Table.swift | 25 ++++++++++++------ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Containers/List.swift b/Sources/SkipUI/SkipUI/Containers/List.swift index bc9073d1..ab836070 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -33,6 +33,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,6 +52,7 @@ import androidx.compose.ui.zIndex import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableLazyListState @@ -199,6 +201,23 @@ public final class List : View { } } PreferenceValues.shared.contribute(context: context, key: ScrollToIDPreferenceKey.self, value: scrollToID) + + // List item animations in Compose work by setting the `animateItemPlacement` modifier on the items. Critically, + // this must be done when the items are composed *prior* to any animated change. So by default we compose all items + // with `animateItemPlacement`. If the entire List is recomposed without an animation in progress (e.g. an unanimated + // data change), we recompose without animation, then after some time to complete the recompose we flip back to the + // animated state in anticipation of the next, potentially animated, update + let forceUnanimatedItems = remember { mutableStateOf(false) } + if Animation.current(isAnimating: false) == nil { + forceUnanimatedItems.value = true + LaunchedEffect(System.currentTimeMillis()) { + delay(300) + forceUnanimatedItems.value = false + } + } else { + forceUnanimatedItems.value = false + } + LazyColumn(state: reorderableState.listState, modifier: modifier) { let sectionHeaderContext = context.content(composer: RenderingComposer { view, context in ComposeSectionHeader(view: view, context: context(false), styling: styling, isTop: false) @@ -212,9 +231,9 @@ public final class List : View { // Read move trigger here so that a move will recompose list content let _ = moveTrigger.value - // Animate list operations. If we're filtering, however, we disable animation to prevent weird animations let shouldAnimateItems: @Composable () -> Bool = { - EnvironmentValues.shared._searchableState?.isFiltering() != true + // We disable animation to prevent filtered items from animating when they return + !forceUnanimatedItems.value && EnvironmentValues.shared._searchableState?.isFiltering() != true } // Initialize the factory context with closures that use the LazyListScope to generate items @@ -401,9 +420,9 @@ public final class List : View { if isDeleteEnabled { let rememberedOnDelete = rememberUpdatedState({ if let onDelete { - onDelete(IndexSet(integer: index)) + withAnimation { onDelete(IndexSet(integer: index)) } } else if let objectsBinding, objectsBinding.wrappedValue.count > index { - (objectsBinding.wrappedValue as? RangeReplaceableCollection)?.remove(at: index) + withAnimation { (objectsBinding.wrappedValue as? RangeReplaceableCollection)?.remove(at: index) } } }) let coroutineScope = rememberCoroutineScope() diff --git a/Sources/SkipUI/SkipUI/Containers/Table.swift b/Sources/SkipUI/SkipUI/Containers/Table.swift index b7444a0d..d82163b3 100644 --- a/Sources/SkipUI/SkipUI/Containers/Table.swift +++ b/Sources/SkipUI/SkipUI/Containers/Table.swift @@ -22,7 +22,9 @@ import androidx.compose.foundation.layout.requiredWidthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -32,6 +34,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlinx.coroutines.launch #endif @@ -89,15 +92,21 @@ public final class Table : View where ObjectType: Identifiable Bool = { - guard let searchableState = EnvironmentValues.shared._searchableState, searchableState.isSearching.value else { - return true - } - guard searchableState.isModifierOnNavigationStack else { - return false + // See explanation in List.swift + let forceUnanimatedItems = remember { mutableStateOf(false) } + if Animation.current(isAnimating: false) == nil { + forceUnanimatedItems.value = true + LaunchedEffect(System.currentTimeMillis()) { + delay(300) + forceUnanimatedItems.value = false } - // When the .searchable modifier is on the NavigationStack, assume we're the target if we're the root - return LocalNavigator.current?.isRoot != true + } else { + forceUnanimatedItems.value = false + } + + let shouldAnimateItems: @Composable () -> Bool = { + // We disable animation to prevent filtered items from animating when they return + !forceUnanimatedItems.value && EnvironmentValues.shared._searchableState?.isFiltering() != true } let key: (Int) -> String = { composeBundleString(for: data[$0].id) }