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)
+ init(@ViewBuilder content: @escaping () -> any View, @ViewBuilder label: () -> any View)
init(isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View, @ViewBuilder label: () -> any View)
+ init(_ titleKey: LocalizedStringKey, @ViewBuilder content: @escaping () -> any View)
+ init(_ titleResource: LocalizedStringResource, @ViewBuilder content: @escaping () -> any View)
init(_ titleKey: LocalizedStringKey, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
- init(_ titleResource: LocalizedStringResource, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
+ init(_ titleResource: LocalizedStringResource, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
+ init(_ label: String, @ViewBuilder content: @escaping () -> any View)
init(_ label: String, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
- - Does not animate when used as a
List or Form item
- - Always animates when **not** used as a
List or Form item
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)
}