diff --git a/README.md b/README.md index fe30569b..912319a7 100644 --- a/README.md +++ b/README.md @@ -740,12 +740,14 @@ Support levels:
DisclosureGroup (example)
diff --git a/Sources/SkipUI/SkipUI/Containers/DisclosureGroup.swift b/Sources/SkipUI/SkipUI/Containers/DisclosureGroup.swift index 9689257d..0aa377ea 100644 --- a/Sources/SkipUI/SkipUI/Containers/DisclosureGroup.swift +++ b/Sources/SkipUI/SkipUI/Containers/DisclosureGroup.swift @@ -3,12 +3,8 @@ #if !SKIP_BRIDGE import Foundation #if SKIP -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,8 +21,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier @@ -38,21 +32,32 @@ import androidx.compose.ui.unit.dp public struct DisclosureGroup : View, Renderable { let label: ComposeBuilder let content: ComposeBuilder - let expandedBinding: Binding + let expandedBinding: Binding? + let initialExpanded: Bool + #if SKIP + private var internalExpandedState: MutableState? = nil + #endif - // We cannot support this constructor because we have not been able to get expansion working reliably - // in Lists without an external Binding - @available(*, unavailable) public init(@ViewBuilder content: @escaping () -> any View, @ViewBuilder label: () -> any View) { self.label = ComposeBuilder.from(label) self.content = ComposeBuilder.from(content) - self.expandedBinding = Binding(get: { false }, set: { _ in }) + self.expandedBinding = nil + self.initialExpanded = false + } + + // SKIP @bridge + public init(bridgedContent: any View, bridgedLabel: any View) { + self.label = ComposeBuilder.from { bridgedLabel } + self.content = ComposeBuilder.from { bridgedContent } + self.expandedBinding = nil + self.initialExpanded = false } public init(isExpanded: Binding, @ViewBuilder content: @escaping () -> any View, @ViewBuilder label: () -> any View) { self.label = ComposeBuilder.from(label) self.content = ComposeBuilder.from(content) self.expandedBinding = isExpanded + self.initialExpanded = isExpanded.wrappedValue } // SKIP @bridge @@ -60,19 +65,15 @@ public struct DisclosureGroup : View, Renderable { self.label = ComposeBuilder.from { bridgedLabel } self.content = ComposeBuilder.from { bridgedContent } self.expandedBinding = Binding(get: getExpanded, set: setExpanded) + self.initialExpanded = getExpanded() } - @available(*, unavailable) public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: @escaping () -> any View) { - self.label = ComposeBuilder.from({ Text(titleKey) }) - self.content = ComposeBuilder.from(content) - self.expandedBinding = Binding(get: { false }, set: { _ in }) + self.init(content: content, label: { Text(titleKey) }) } public init(_ titleResource: LocalizedStringResource, @ViewBuilder content: @escaping () -> any View) { - self.label = ComposeBuilder.from({ Text(titleResource) }) - self.content = ComposeBuilder.from(content) - self.expandedBinding = Binding(get: { false }, set: { _ in }) + self.init(content: content, label: { Text(titleResource) }) } public init(_ titleKey: LocalizedStringKey, isExpanded: Binding, @ViewBuilder content: @escaping () -> any View) { @@ -83,11 +84,8 @@ public struct DisclosureGroup : View, Renderable { self.init(isExpanded: isExpanded, content: content, label: { Text(titleResource) }) } - @available(*, unavailable) public init(_ label: String, @ViewBuilder content: @escaping () -> any View) { - self.label = ComposeBuilder.from({ Text(verbatim: label) }) - self.content = ComposeBuilder.from(content) - self.expandedBinding = Binding(get: { false }, set: { _ in }) + self.init(content: content, label: { Text(verbatim: label) }) } public init(_ label: String, isExpanded: Binding, @ViewBuilder content: @escaping () -> any View) { @@ -99,6 +97,7 @@ public struct DisclosureGroup : View, Renderable { guard let level = EvaluateOptions(options).lazyItemLevel else { return listOf(self) } + let expandedBinding = resolvedExpandedBinding(context: context) guard expandedBinding.wrappedValue else { return listOf(self) } @@ -107,19 +106,16 @@ public struct DisclosureGroup : View, Renderable { } @Composable override func Render(context: ComposeContext) { + let expandedBinding = resolvedExpandedBinding(context: context) let columnArrangement = Arrangement.spacedBy(8.dp, alignment: androidx.compose.ui.Alignment.CenterVertically) let contentContext = context.content() ComposeContainer(axis: .vertical, modifier: context.modifier, fillWidth: true) { modifier in + let modifier = modifier.fillMaxWidth().animateContentSize(animationSpec: tween(durationMillis: 120)) Column(modifier: modifier, verticalArrangement: columnArrangement, horizontalAlignment: androidx.compose.ui.Alignment.Start) { - RenderLabel(context: contentContext) - // Note: we can't seem to turn *off* animation when in AnimatedContent, so we've removed the code that - // tries. We could take a separate code path to avoid AnimatedContent, but then a change in animation - // status could cause us to lose state - AnimatedContent(targetState: expandedBinding.wrappedValue) { isExpanded in - if isExpanded { - Column(modifier: Modifier.fillMaxWidth(), verticalArrangement: columnArrangement, horizontalAlignment: androidx.compose.ui.Alignment.CenterHorizontally) { - content.Compose(context: contentContext) - } + RenderLabel(context: contentContext, expandedBinding: expandedBinding) + if expandedBinding.wrappedValue { + Column(modifier: Modifier.fillMaxWidth(), verticalArrangement: columnArrangement, horizontalAlignment: androidx.compose.ui.Alignment.CenterHorizontally) { + content.Compose(context: contentContext) } } } @@ -127,26 +123,32 @@ public struct DisclosureGroup : View, Renderable { } @Composable override func shouldRenderListItem(context: ComposeContext) -> (Bool, (() -> Void)?) { - // Attempting to animate the list expansion and contraction doesn't work well and causes artifacts - // in other list items - return (true, { expandedBinding.wrappedValue = !expandedBinding.wrappedValue }) + let expandedBinding = resolvedExpandedBinding(context: context) + return (true, { + withAnimation { + expandedBinding.wrappedValue = !expandedBinding.wrappedValue + } + }) } @Composable public func RenderListItem(context: ComposeContext, modifiers: kotlin.collections.List) { + let expandedBinding = resolvedExpandedBinding(context: context) ModifiedContent.RenderWithModifiers(modifiers, context: context) { - RenderLabel(context: $0, isListItem: true) + RenderLabel(context: $0, isListItem: true, expandedBinding: expandedBinding) } } - @Composable func RenderLabel(context: ComposeContext, isListItem: Bool = false) { + @Composable func RenderLabel(context: ComposeContext, isListItem: Bool = false, expandedBinding: Binding? = nil) { + let expandedBinding = expandedBinding ?? resolvedExpandedBinding(context: context) let contentContext = context.content() let isEnabled = EnvironmentValues.shared.isEnabled let (foregroundStyle, accessoryColor) = composeStyles(isEnabled: isEnabled, isListItem: isListItem) let rotationAngle = Float(expandedBinding.wrappedValue ? 90 : 0).asAnimatable(context: contentContext) let isRTL = EnvironmentValues.shared.layoutDirection == .rightToLeft - let modifier: Modifier = isEnabled && !isListItem ? context.modifier.clickable(onClick: { - withAnimation { expandedBinding.wrappedValue = !expandedBinding.wrappedValue } - }) : context.modifier + let baseModifier = context.modifier.fillMaxWidth() + let modifier: Modifier = isEnabled && !isListItem ? baseModifier.clickable(onClick: { + expandedBinding.wrappedValue = !expandedBinding.wrappedValue + }) : baseModifier Row(modifier: modifier, verticalAlignment: androidx.compose.ui.Alignment.CenterVertically) { Box(modifier: Modifier.padding(end: 8.dp).weight(Float(1.0))) { EnvironmentValues.shared.setValues { @@ -162,6 +164,19 @@ public struct DisclosureGroup : View, Renderable { } } + @Composable private func resolvedExpandedBinding(context: ComposeContext) -> Binding { + if let expandedBinding { + return expandedBinding + } + if internalExpandedState == nil { + internalExpandedState = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(initialExpanded) } + } + return Binding( + get: { internalExpandedState?.value ?? initialExpanded }, + set: { internalExpandedState?.value = $0 } + ) + } + @Composable private func composeStyles(isEnabled: Bool, isListItem: Bool) -> (ShapeStyle?, androidx.compose.ui.graphics.Color) { var foregroundStyle: ShapeStyle? = nil if !isListItem { diff --git a/Sources/SkipUI/SkipUI/Containers/ForEach.swift b/Sources/SkipUI/SkipUI/Containers/ForEach.swift index b9029b41..a664f197 100644 --- a/Sources/SkipUI/SkipUI/Containers/ForEach.swift +++ b/Sources/SkipUI/SkipUI/Containers/ForEach.swift @@ -198,8 +198,12 @@ public final class ForEach : View, Renderable, LazyItemFactory { return true } // We have to unroll if the ForEach body contains multiple views. We also unroll if this is - // e.g. a ForEach of Sections which each append lazy items - return renderables.size > 1 || (renderables.firstOrNull() as? LazyItemFactory)?.shouldProduceLazyItems() == true + // e.g. a ForEach of Sections which each append lazy items. A DisclosureGroup can also + // append lazy child items when expanded, even when it is currently collapsed to one row. + let firstRenderable = renderables.firstOrNull() + return renderables.size > 1 + || (firstRenderable as? LazyItemFactory)?.shouldProduceLazyItems() == true + || firstRenderable?.strip() is DisclosureGroup } override func produceLazyItems(collector: LazyItemCollector, modifiers: kotlin.collections.List, level: Int) { diff --git a/Sources/SkipUI/SkipUI/Containers/LazyHGrid.swift b/Sources/SkipUI/SkipUI/Containers/LazyHGrid.swift index b28a9619..e83b497a 100644 --- a/Sources/SkipUI/SkipUI/Containers/LazyHGrid.swift +++ b/Sources/SkipUI/SkipUI/Containers/LazyHGrid.swift @@ -152,7 +152,7 @@ public struct LazyHGrid: View, Renderable { } } }, - sectionHeader: { content in + sectionHeader: { content, _ in for renderable in content { item(span: { GridItemSpan(maxLineSpan) }) { Box(contentAlignment: androidx.compose.ui.Alignment.Center) { @@ -160,8 +160,9 @@ public struct LazyHGrid: View, Renderable { } } } + return max(1, content.size) }, - sectionFooter: { content in + sectionFooter: { content, _, _ in for renderable in content { item(span: { GridItemSpan(maxLineSpan) }) { Box(contentAlignment: androidx.compose.ui.Alignment.Center) { @@ -169,6 +170,7 @@ public struct LazyHGrid: View, Renderable { } } } + return max(1, content.size) } ) for renderable in renderables { diff --git a/Sources/SkipUI/SkipUI/Containers/LazyHStack.swift b/Sources/SkipUI/SkipUI/Containers/LazyHStack.swift index a9d93166..7071b4d6 100644 --- a/Sources/SkipUI/SkipUI/Containers/LazyHStack.swift +++ b/Sources/SkipUI/SkipUI/Containers/LazyHStack.swift @@ -138,19 +138,21 @@ public struct LazyHStack : View, Renderable { factory(objectsBinding, index, context.content(scope: self)).Render(context: context.content(scope: self)) } }, - sectionHeader: { content in + sectionHeader: { content, _ in for renderable in content { item { renderable.Render(context: context.content(scope: self)) } } + return max(1, content.size) }, - sectionFooter: { content in + sectionFooter: { content, _, _ in for renderable in content { item { renderable.Render(context: context.content(scope: self)) } } + return max(1, content.size) } ) for renderable in renderables { diff --git a/Sources/SkipUI/SkipUI/Containers/LazySupport.swift b/Sources/SkipUI/SkipUI/Containers/LazySupport.swift index 40a4025a..5f938b8e 100644 --- a/Sources/SkipUI/SkipUI/Containers/LazySupport.swift +++ b/Sources/SkipUI/SkipUI/Containers/LazySupport.swift @@ -88,7 +88,7 @@ final class LazySectionHeader: Renderable, LazyItemFactory { override func produceLazyItems(collector: LazyItemCollector, modifiers: kotlin.collections.List, level: Int) { let modified = content.map { ModifiedContent.apply(modifiers: modifiers, to: $0) } - collector.sectionHeader(modified) + collector.sectionHeader(modified, LazyItemCollector.sectionIdentity(from: modifiers)) } } @@ -106,7 +106,7 @@ final class LazySectionFooter: Renderable, LazyItemFactory { override func produceLazyItems(collector: LazyItemCollector, modifiers: kotlin.collections.List, level: Int) { let modified = content.map { ModifiedContent.apply(modifiers: modifiers, to: $0) } - collector.sectionFooter(modified) + collector.sectionFooter(modified, LazyItemCollector.sectionIdentity(from: modifiers)) } } @@ -126,70 +126,103 @@ public final class LazyItemCollector { private(set) var indexedItems: (Range, ((Any) -> AnyHashable?)?, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, Int, @Composable (Int, ComposeContext) -> Renderable) -> Void = { _, _, _, _, _, _ in } private(set) var objectItems: (RandomAccessCollection, (Any) -> AnyHashable?, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, Int, @Composable (Any, ComposeContext) -> Renderable) -> Void = { _, _, _, _, _, _ in } private(set) var objectBindingItems: (Binding>, (Any) -> AnyHashable?, EditActions, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, Int, @Composable (Binding>, Int, ComposeContext) -> Renderable) -> Void = { _, _, _, _, _, _, _ in } - private(set) var sectionHeader: (kotlin.collections.List) -> Void = { _ in } - private(set) var sectionFooter: (kotlin.collections.List) -> Void = { _ in } + private(set) var sectionHeader: (kotlin.collections.List, Any?) -> Int = { _, _ in 0 } + private(set) var sectionFooter: (kotlin.collections.List, Any?) -> Int = { _, _ in 0 } + private var currentSectionItemCount: Int? = nil private var startItemIndex = 0 + /// Track emitted body items for the current section, excluding section header/footer chrome. + private func incrementCurrentSectionItemCount(by count: Int) { + if let countInSection = currentSectionItemCount { + currentSectionItemCount = countInSection + count + } + } + /// Initialize the content factories. + /// + /// Section callbacks return the number of lazy items they actually emitted. `List` needs this because grouped + /// section chrome can emit extra lazy items, such as the gap before non-top sections. func initialize( startItemIndex: Int, item: (Renderable, Int) -> Void, indexedItems: (Range, ((Any) -> AnyHashable?)?, Int, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, Int, @Composable (Int, ComposeContext) -> Renderable) -> Void, objectItems: (RandomAccessCollection, (Any) -> AnyHashable?, Int, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, Int, @Composable (Any, ComposeContext) -> Renderable) -> Void, objectBindingItems: (Binding>, (Any) -> AnyHashable?, Int, EditActions, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, Int, @Composable (Binding>, Int, ComposeContext) -> Renderable) -> Void, - sectionHeader: (kotlin.collections.List) -> Void, - sectionFooter: (kotlin.collections.List) -> Void + sectionHeader: (kotlin.collections.List, Any?) -> Int, + sectionFooter: (kotlin.collections.List, Any?, Int?) -> Int ) { self.startItemIndex = startItemIndex content.removeAll() + currentSectionItemCount = nil + self.item = { renderable, level in // If this is an item after a section, add a header before it if case .sectionFooter = content.last { - self.sectionHeader(listOf()) + _ = self.sectionHeader(listOf(), nil) } item(renderable, level) let id = TagModifier.on(content: renderable, role: .id)?.value content.append(.items(0, 1, { _ in id }, nil)) + incrementCurrentSectionItemCount(by: 1) } self.indexedItems = { range, identifier, onDelete, onMove, level, factory in if case .sectionFooter = content.last { - self.sectionHeader(listOf()) + _ = self.sectionHeader(listOf(), nil) } indexedItems(range, identifier, count, onDelete, onMove, level, factory) - content.append(.items(range.start, range.endExclusive - range.start, identifier, onMove)) + let itemCount = range.endExclusive - range.start + content.append(.items(range.start, itemCount, identifier, onMove)) + incrementCurrentSectionItemCount(by: itemCount) } self.objectItems = { objects, identifier, onDelete, onMove, level, factory in if case .sectionFooter = content.last { - self.sectionHeader(listOf()) + _ = self.sectionHeader(listOf(), nil) } objectItems(objects, identifier, count, onDelete, onMove, level, factory) content.append(.objectItems(objects, identifier, onMove)) + incrementCurrentSectionItemCount(by: objects.count) } self.objectBindingItems = { binding, identifier, editActions, onDelete, onMove, level, factory in if case .sectionFooter = content.last { - self.sectionHeader(listOf()) + _ = self.sectionHeader(listOf(), nil) } objectBindingItems(binding, identifier, count, editActions, onDelete, onMove, level, factory) content.append(.objectBindingItems(binding, identifier, onMove)) + incrementCurrentSectionItemCount(by: binding.wrappedValue.count) } - self.sectionHeader = { renderables in + self.sectionHeader = { renderables, sectionIdentity in // If this is a header after an item, add a section footer before it switch content.last { case .sectionFooter, nil: break default: - self.sectionFooter(listOf()) + _ = self.sectionFooter(listOf(), nil) } - sectionHeader(renderables) - content.append(.sectionHeader(max(1, renderables.size))) + let renderedCount = max(1, sectionHeader(renderables, sectionIdentity)) + content.append(.sectionHeader(renderedCount)) + currentSectionItemCount = 0 + return renderedCount } - self.sectionFooter = { renderables in - sectionFooter(renderables) - content.append(.sectionFooter(max(1, renderables.size))) + self.sectionFooter = { renderables, sectionIdentity in + let renderedCount = max(1, sectionFooter(renderables, sectionIdentity, currentSectionItemCount)) + content.append(.sectionFooter(renderedCount)) + currentSectionItemCount = nil + return renderedCount } } + /// Return the section identity supplied by an enclosing ForEach, if one exists. + static func sectionIdentity(from modifiers: kotlin.collections.List) -> Any? { + for modifier in modifiers { + if modifier.role == ModifierRole.tag, let tagModifier = modifier as? TagModifier { + return tagModifier.value + } + } + + return nil + } + /// The current number of content items. var count: Int { var itemCount = 0 @@ -205,6 +238,14 @@ public final class LazyItemCollector { return itemCount } + /// If the current collected content ends with a section footer. + var endsWithSectionFooter: Bool { + if case .sectionFooter = content.last { + return true + } + return false + } + /// Return the list index for the given item ID, or nil. func index(for id: Any) -> Int? { var index = startItemIndex @@ -312,6 +353,11 @@ public final class LazyItemCollector { } } + /// Whether a move operation is currently active. + var hasActiveMove: Bool { + return moving != nil + } + /// Commit the current active move operation, if any. func commitMove() { guard let moving else { diff --git a/Sources/SkipUI/SkipUI/Containers/LazyVGrid.swift b/Sources/SkipUI/SkipUI/Containers/LazyVGrid.swift index 25c4dcfa..37179bba 100644 --- a/Sources/SkipUI/SkipUI/Containers/LazyVGrid.swift +++ b/Sources/SkipUI/SkipUI/Containers/LazyVGrid.swift @@ -169,7 +169,7 @@ public struct LazyVGrid: View, Renderable { } } }, - sectionHeader: { content in + sectionHeader: { content, _ in for renderable in content { item(span: { GridItemSpan(maxLineSpan) }) { Box(contentAlignment: androidx.compose.ui.Alignment.Center) { @@ -177,8 +177,9 @@ public struct LazyVGrid: View, Renderable { } } } + return max(1, content.size) }, - sectionFooter: { content in + sectionFooter: { content, _, _ in for renderable in content { item(span: { GridItemSpan(maxLineSpan) }) { Box(contentAlignment: androidx.compose.ui.Alignment.Center) { @@ -186,6 +187,7 @@ public struct LazyVGrid: View, Renderable { } } } + return max(1, content.size) } ) if isSearchable { diff --git a/Sources/SkipUI/SkipUI/Containers/LazyVStack.swift b/Sources/SkipUI/SkipUI/Containers/LazyVStack.swift index 5e15c40a..567a8e3f 100644 --- a/Sources/SkipUI/SkipUI/Containers/LazyVStack.swift +++ b/Sources/SkipUI/SkipUI/Containers/LazyVStack.swift @@ -154,19 +154,21 @@ public struct LazyVStack : View, Renderable { factory(objectsBinding, index, scopedContext).Render(context: scopedContext) } }, - sectionHeader: { content in + sectionHeader: { content, _ in for renderable in content { item { renderable.Render(context: context.content(scope: self)) } } + return max(1, content.size) }, - sectionFooter: { content in + sectionFooter: { content, _, _ in for renderable in content { item { renderable.Render(context: context.content(scope: self)) } } + return max(1, content.size) } ) if isSearchable { diff --git a/Sources/SkipUI/SkipUI/Containers/List.swift b/Sources/SkipUI/SkipUI/Containers/List.swift index e7d029fc..5dd1625a 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -56,6 +56,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -263,8 +264,13 @@ public final class List : View, Renderable { // 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 + // animated state in anticipation of the next, potentially animated, update. let forceUnanimatedItems = remember { mutableStateOf(false) } + + // Section chrome has separate placement rules from rows: row inserts/deletes should animate, while + // initial/bulk section loads need their rounded bottom chrome to appear at the settled position. + let sectionChromeAnimationState = remember { SectionChromeAnimationState() } + let sectionBottomPlacementInvalidation = remember { mutableStateOf(0) } if Animation.current(isAnimating: false) == nil { forceUnanimatedItems.value = true LaunchedEffect(System.currentTimeMillis()) { @@ -280,6 +286,7 @@ public final class List : View, Renderable { opens, all others observe this state and animate closed. Matches iOS list behavior of "only one row's swipe actions visible at once". */ let activeSwipeKey = remember { mutableStateOf(nil) } + // Combine contentPadding with contentMargins additively var contentPadding = EnvironmentValues.shared._contentPadding.asPaddingValues() if let contentMargins = EnvironmentValues.shared._contentMargins?.asComposePaddingValues(for: .automatic) { @@ -288,6 +295,9 @@ public final class List : View, Renderable { let listRowSpacing = EnvironmentValues.shared._listRowSpacing let listVerticalArrangement = listRowSpacing != nil ? Arrangement.spacedBy(listRowSpacing!.dp) : Arrangement.Top LazyColumn(state: reorderableState.listState, modifier: modifier, contentPadding: contentPadding, verticalArrangement: listVerticalArrangement) { + // Intentionally invalidate the LazyColumn after a section bottom has seen one frame at its unanimated baseline + let _ = sectionBottomPlacementInvalidation.value + // Read move trigger here so that a move will recompose list content let _ = moveTrigger.value let shouldAnimateItems: @Composable () -> Bool = { @@ -297,14 +307,56 @@ public final class List : View, Renderable { } // Initialize the factory context with closures that use the LazyListScope to generate items + var itemKeyOccurrences = mutableMapOf() + var currentSectionBodyItemCounts: [String: Int] = [:] + var implicitPathComponents: [String] = [] + var implicitSiblingOccurrences: [Int: Int] = [:] + + // Build stable Compose keys for non-ForEach renderables. Explicit IDs/tags become path + // components, while untagged siblings get occurrence-based components so nested content + // does not accidentally reuse another item's placement animation state. + let itemKey: (Renderable, Int) -> String = { renderable, level in + let identity = TagModifier.on(content: renderable, role: .id)?.value + ?? TagModifier.on(content: renderable, role: .tag)?.value + let siblingOccurrence = implicitSiblingOccurrences[level] ?? 0 + implicitSiblingOccurrences[level] = siblingOccurrence + 1 + implicitSiblingOccurrences.keys.filter { $0 > level }.forEach { + implicitSiblingOccurrences.removeValue(forKey: $0) + } + while implicitPathComponents.count > level { + implicitPathComponents.removeLast() + } + + let pathComponent: String + if let identity { + pathComponent = "explicit:\(composeBundleString(for: identity))" + } else { + pathComponent = "implicit:\(siblingOccurrence)" + } + implicitPathComponents.append(pathComponent) + + let baseKey: String + if let identity { + baseKey = "explicit-path:\(implicitPathComponents.joined(separator: "/"))" + } else { + baseKey = "implicit-path:\(implicitPathComponents.joined(separator: "/"))" + } + + let occurrence = itemKeyOccurrences[baseKey] ?? 0 + itemKeyOccurrences[baseKey] = occurrence + 1 + return "\(baseKey)#\(occurrence)" + } + + var sectionIndex = -1 var startItemIndex = hasHeader ? 1 : 0 // Header inset if isSearchable { startItemIndex += 1 // Search field } + itemCollector.value.initialize( startItemIndex: startItemIndex, item: { renderable, level in - item { + item(key: itemKey(renderable, level)) { let itemModifier: Modifier = shouldAnimateItems() ? Modifier.animateItem() : Modifier RenderItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling) } @@ -340,29 +392,97 @@ public final class List : View, Renderable { RenderEditableItem(content: renderable, level: level, context: itemContext, modifier: itemModifier, styling: styling, objectsBinding: objectsBinding, key: keyValue, index: index, editActions: editActions, onDelete: onDelete, onMove: onMove, reorderableState: reorderableState, activeSwipeKey: activeSwipeKey) } }, - sectionHeader: { content in + sectionHeader: { content, sectionIdentity in + sectionIndex += 1 + let currentSectionIndex = sectionIndex + let sectionKey = Self.sectionKey(for: sectionIdentity, fallbackIndex: currentSectionIndex) let headerRenderables = content.size == 0 ? listOf(EmptyView()) : content let firstRenderable = (renderables.firstOrNull() as? LazySectionHeader)?.content.firstOrNull() let isTop = firstRenderable === headerRenderables.firstOrNull() - for renderable in headerRenderables { + var renderedCount = 0 + for renderableIndex in 0.. previousSectionItemCount ? currentSectionItemCount - previousSectionItemCount : previousSectionItemCount - currentSectionItemCount + shouldAnimateFooterPlacement = countDelta <= 1 + } else { + shouldAnimateFooterPlacement = false + } + + if !shouldAnimateFooterPlacement { + // First/bulk layouts establish their footer baseline without placement animation. The next + // frame enables normal placement animation for small changes such as DisclosureGroup toggles. + sectionChromeAnimationState.sectionBottomPlacementReady[sectionKey] = false + if sectionChromeAnimationState.sectionBottomPlacementPending[sectionKey] != true { + sectionChromeAnimationState.sectionBottomPlacementPending[sectionKey] = true + coroutineScope.launch { + // SKIP INSERT: withFrameNanos { _ -> } + sectionChromeAnimationState.sectionBottomPlacementReady[sectionKey] = true + sectionChromeAnimationState.sectionBottomPlacementPending[sectionKey] = false + sectionBottomPlacementInvalidation.value = sectionBottomPlacementInvalidation.value + 1 + } } } + + var renderedCount = 0 + for renderableIndex in 0.. previousListItemCount ? currentListItemCount - previousListItemCount : previousListItemCount - currentListItemCount + shouldAnimateListFooterPlacement = countDelta <= 1 + } else { + shouldAnimateListFooterPlacement = false + } + + sectionChromeAnimationState.listItemCount = currentListItemCount + + let hasBottomSection = itemCollector.value.endsWithSectionFooter if hasFooter { - let hasBottomSection = renderables.lastOrNull() is LazySectionFooter - item { - RenderFooter(styling: styling, safeAreaHeight: arguments.footerSafeAreaHeight, hasBottomSection: hasBottomSection) + item(key: "list-footer") { + let itemModifier: Modifier + if itemCollector.value.hasActiveMove { + itemModifier = Modifier + } else if shouldAnimateListFooterPlacement && EnvironmentValues.shared._searchableState?.isSearching.value != true { + itemModifier = Modifier.animateItem(fadeInSpec: nil, fadeOutSpec: nil) + } else { + itemModifier = Modifier.animateItem(fadeInSpec: nil, placementSpec: nil, fadeOutSpec: nil) + } + RenderFooter(styling: styling, modifier: itemModifier, safeAreaHeight: arguments.footerSafeAreaHeight, hasBottomSection: hasBottomSection) } } } } - + + /// Build a stable key for section chrome animation state. + private static func sectionKey(for sectionIdentity: Any?, fallbackIndex: Int) -> String { + guard let sectionIdentity else { + // Sections without an explicit identity can only be tracked by their rendered order. + return "index-\(fallbackIndex)" + } + + // Prefer explicit Section/ForEach identity so chrome animation state follows the section + // across insertions and deletions instead of sticking to a numeric position. + return "id-\(composeBundleString(for: sectionIdentity))" + } + + /// Tracks section chrome placement separately from row animation state. + private final class SectionChromeAnimationState { + var sectionBodyItemCounts: [String: Int] = [:] + var sectionBottomPlacementReady: [String: Bool] = [:] + var sectionBottomPlacementPending: [String: Bool] = [:] + var listItemCount: Int? = nil + } + private static let horizontalInset = 16.0 private static let verticalInset = 16.0 private static let minimumItemHeight = 32.0 @@ -1047,13 +1210,9 @@ public final class List : View, Renderable { } } - @Composable private func RenderSectionHeader(content: Renderable, context: ComposeContext, styling: ListStyling, isTop: Bool) { - if !isTop && styling.style != ListStyle.plain { - // Vertical padding - RenderFooter(styling: styling, safeAreaHeight: 0.dp, hasBottomSection: true) - } + @Composable private func RenderSectionHeader(content: Renderable, context: ComposeContext, modifier: Modifier = Modifier, styling: ListStyling, isTop: Bool) { let backgroundColor = BackgroundColor(styling: styling, isItem: false) - let modifier = Modifier + let containerModifier = modifier.fillMaxWidth() .zIndex(Float(0.5)) .background(backgroundColor) .then(context.modifier) @@ -1063,7 +1222,7 @@ public final class List : View, Renderable { } else { contentModifier = contentModifier.padding(horizontal: Self.horizontalItemInset.dp, vertical: Self.verticalItemInset.dp) } - Box(modifier: modifier, contentAlignment: androidx.compose.ui.Alignment.BottomCenter) { + Box(modifier: containerModifier, contentAlignment: androidx.compose.ui.Alignment.BottomCenter) { Column(modifier: Modifier.fillMaxWidth()) { EnvironmentValues.shared.setValues { $0.set_listSectionHeaderStyle(styling.style) @@ -1078,7 +1237,7 @@ public final class List : View, Renderable { } } - @Composable private func RenderSectionFooter(content: Renderable, context: ComposeContext, styling: ListStyling) { + @Composable private func RenderSectionFooter(content: Renderable, context: ComposeContext, modifier: Modifier = Modifier, styling: ListStyling) { if styling.style == .plain { let footerContent: Renderable if let lazySectionFooter = content as? LazySectionFooter, !lazySectionFooter.content.any({ !$0.isSwiftUIEmptyView }) { @@ -1087,15 +1246,15 @@ public final class List : View, Renderable { } else { footerContent = content } - RenderItem(content: footerContent, level: 0, context: context, styling: styling, isItem: false) + RenderItem(content: footerContent, level: 0, context: context, modifier: modifier, styling: styling, isItem: false) } else { let backgroundColor = BackgroundColor(styling: styling, isItem: false) - let modifier = Modifier.offset(y: -1.dp) // Cover last row's divider + let containerModifier = modifier.offset(y: -1.dp) // Cover last row's divider .zIndex(Float(0.5)) .background(backgroundColor) .then(context.modifier) let contentModifier = Modifier.fillMaxWidth().padding(horizontal: Self.horizontalItemInset.dp, vertical: Self.verticalItemInset.dp) - Box(modifier: modifier, contentAlignment: androidx.compose.ui.Alignment.TopCenter) { + Box(modifier: containerModifier, contentAlignment: androidx.compose.ui.Alignment.TopCenter) { Column(modifier: Modifier.fillMaxWidth().heightIn(min: 1.dp)) { EnvironmentValues.shared.setValues { $0.set_listSectionFooterStyle(styling.style) @@ -1130,7 +1289,7 @@ public final class List : View, Renderable { /// - Warning: Only call for non-.plain styles or with a positive safe area height. This is distinct from having this function detect /// .plain and zero-height and return without rendering. That causes .plain style lists to have a weird rubber banding effect on overscroll. - @Composable private func RenderFooter(styling: ListStyling, safeAreaHeight: Dp, hasBottomSection: Bool) { + @Composable private func RenderFooter(styling: ListStyling, modifier: Modifier = Modifier, safeAreaHeight: Dp, hasBottomSection: Bool) { var height = safeAreaHeight var offset = 0.dp if styling.style != .plain { @@ -1138,12 +1297,12 @@ public final class List : View, Renderable { offset = -1.dp // Cover last row's divider } let backgroundColor = BackgroundColor(styling: styling, isItem: false) - let modifier = Modifier.fillMaxWidth() + let containerModifier = modifier.fillMaxWidth() .height(height) .offset(y: offset) .zIndex(Float(0.5)) .background(backgroundColor) - Box(modifier: modifier, contentAlignment: androidx.compose.ui.Alignment.TopCenter) { + Box(modifier: containerModifier, contentAlignment: androidx.compose.ui.Alignment.TopCenter) { if !hasBottomSection && styling.style != .plain { RenderRoundedCorners(isTop: false, fill: backgroundColor) }