Skip to content

Commit

Permalink
Merge pull request #49 from skiptools/listanimation
Browse files Browse the repository at this point in the history
Reduce excessive animating of list items
  • Loading branch information
aabewhite authored Sep 10, 2024
2 parents 4d5801a + 9ec2870 commit 00801e8
Show file tree
Hide file tree
Showing 2 changed files with 40 additions and 12 deletions.
27 changes: 23 additions & 4 deletions Sources/SkipUI/SkipUI/Containers/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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<Any>)?.remove(at: index)
withAnimation { (objectsBinding.wrappedValue as? RangeReplaceableCollection<Any>)?.remove(at: index) }
}
})
let coroutineScope = rememberCoroutineScope()
Expand Down
25 changes: 17 additions & 8 deletions Sources/SkipUI/SkipUI/Containers/Table.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -89,15 +92,21 @@ public final class Table<ObjectType, ID> : View where ObjectType: Identifiable<I
}
})

let shouldAnimateItems: @Composable () -> 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) }
Expand Down

0 comments on commit 00801e8

Please sign in to comment.