fluidgroup/swiftui-list-support
A comprehensive collection of essential components for building advanced list-based UIs in SwiftUI, with high-performance UIKit bridges when needed.
Requirements
- iOS 17.0+
- macOS 15.0+
- Swift 6.0
Installation
Swift Package Manager
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/FluidGroup/swiftui-list-support.git", from: "1.0.0")
]Or add it through Xcode:
- Go to File → Add Package Dependencies
- Enter the repository URL:
https://github.com/FluidGroup/swiftui-list-support.git - Select the modules you need
Modules
1. DynamicList - UIKit UICollectionView Bridge
A UIKit-based UICollectionView implementation wrapped for SwiftUI, providing maximum performance for large datasets with SwiftUI cell hosting.
Key Components:
- DynamicListView: UIKit UICollectionView with NSDiffableDataSource
- VersatileCell: Flexible cell supporting SwiftUI content via hosting
- ContentPagingTrigger: Automatic content loading as user scrolls
- CellState: Custom state storage for cells
SwiftUI Usage:
import DynamicList
import SwiftUI
struct MyListView: View {
var body: some View {
DynamicList(
snapshot: snapshot,
layout: {
UICollectionViewCompositionalLayout.list(
using: .init(appearance: .plain)
)
},
scrollDirection: .vertical
) { context in
context.cell { state in
// SwiftUI content in cell
Text("Item: \(context.data.title)")
.padding()
}
}
.incrementalContentLoading {
await loadMoreData()
}
}
}Custom Cell States:
// Define custom state key
enum IsArchivedKey: CustomStateKey {
typealias Value = Bool
static var defaultValue: Bool { false }
}
// Extend CellState
extension CellState {
var isArchived: Bool {
get { self[IsArchivedKey.self] }
set { self[IsArchivedKey.self] = newValue }
}
}2. CollectionView - Pure SwiftUI Layouts
Pure SwiftUI implementation using native components like ScrollView with Lazy stacks. Not based on UICollectionView.
Layout Options:
.list: ScrollView with LazyVStack/LazyHStack.grid(...): ScrollView with LazyVGrid/LazyHGrid.platformList: Native SwiftUI List
Basic List Layout:
import CollectionView
import SwiftUI
struct ContentView: View {
var body: some View {
CollectionView(layout: .list) {
ForEach(items) { item in
ItemView(item: item)
}
}
}
}Grid Layout:
CollectionView(
layout: .grid(
gridItems: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
],
direction: .vertical,
spacing: 8
)
) {
ForEach(items) { item in
GridItemView(item: item)
}
}Infinite Scrolling with ScrollTracking:
struct InfiniteFeedView: View {
@State private var items: [FeedItem] = []
@State private var isLoading = false
@State private var hasError = false
@State private var currentPage = 1
var body: some View {
CollectionView(layout: .list) {
ForEach(items) { item in
FeedItemView(item: item)
}
// Loading indicator at the bottom
if isLoading {
HStack {
ProgressView()
Text("Loading more...")
}
.frame(maxWidth: .infinity)
.padding()
}
}
.onAdditionalLoading(
isEnabled: !hasError, // Disable when there's an error
leadingScreens: 1.5, // Trigger 1.5 screens before the end
isLoading: $isLoading,
onLoad: {
await loadMoreItems()
}
)
.onAppear {
if items.isEmpty {
Task {
await loadMoreItems()
}
}
}
}
private func loadMoreItems() async {
do {
let newItems = try await APIClient.fetchFeedItems(page: currentPage)
if !newItems.isEmpty {
items.append(contentsOf: newItems)
currentPage += 1
hasError = false
}
} catch {
hasError = true
print("Failed to load items: \(error)")
}
}
}Advanced Integration Example:
struct AdvancedCollectionView: View {
@State private var items: [Item] = []
@State private var selectedItems: Set<Item.ID> = []
@State private var isLoading = false
var body: some View {
CollectionView(
layout: .grid(
gridItems: Array(repeating: GridItem(.flexible()), count: 2),
direction: .vertical,
spacing: 8
)
) {
// Header with refresh control (from PullingControl module)
RefreshControl(
threshold: 60,
action: {
await refreshAllItems()
}
) { context in
// Custom refresh indicator
}
// Selectable items with infinite scrolling
SelectableForEach(
data: items,
selection: .multiple(
selected: selectedItems,
canSelectMore: selectedItems.count < 10,
onChange: handleSelection
)
) { index, item in
GridItemView(item: item)
}
}
.onAdditionalLoading(
isLoading: $isLoading,
onLoad: {
await loadMoreItems()
}
)
}
private func handleSelection(_ item: Item.ID, _ action: SelectAction) {
switch action {
case .selected:
selectedItems.insert(item)
case .deselected:
selectedItems.remove(item)
}
}
}
### 3. SelectableForEach
A ForEach alternative that adds selection capabilities with environment values for selection state. Works with any container view - List, ScrollView, VStack, or custom containers.
#### Single Selection:
import SelectableForEach
struct SelectableListView: View { @State private var selectedItem: Item.ID?
var body: some View { List { SelectableForEach( data: items, selection: .single( selected: selectedItem, onChange: { newSelection in selectedItem = newSelection } ) ) { index, item in ItemCell(item: item) } } } }
#### Multiple Selection:
struct MultiSelectListView: View { @State private var selectedItems = Set<Item.ID>()
var body: some View { ScrollView { LazyVStack { SelectableForEach( data: items, selection: .multiple( selected: selectedItems, canSelectMore: true, onChange: { selectedItem, action in switch action { case .selected: selectedItems.insert(selectedItem) case .deselected: selectedItems.remove(selectedItem) } } ) ) { index, item in ItemCell(item: item) } } } } }
#### Accessing Selection State in Child Views:
struct ItemCell: View { let item: Item @Environment(\.selectableForEach_isSelected) var isSelected @Environment(\.selectableForEach_updateSelection) var updateSelection
var body: some View { HStack { Text(item.title) Spacer() if isSelected { Image(systemName: "checkmark") } } .contentShape(Rectangle()) .onTapGesture { updateSelection(!isSelected) } .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) } }
#### Usage with Any Container:
// With native SwiftUI List List { SelectableForEach(data: items, selection: selection) { index, item in ItemRow(item: item) } }
// With VStack VStack { SelectableForEach(data: items, selection: selection) { index, item in ItemCard(item: item) } }
// With CollectionView CollectionView(layout: .grid(...)) { SelectableForEach(data: items, selection: selection) { index, item in GridItem(item: item) } }
### 4. ScrollTracking
Provides infinite scrolling (additional loading) functionality for SwiftUI ScrollView and List views. Automatically triggers loading when the user approaches the end of scrollable content.
import ScrollTracking
struct InfiniteScrollView: View { @State private var items = Array(0..<20) @State private var isLoading = false
var body: some View { ScrollView { LazyVStack { ForEach(items, id: \.self) { index in Text("Item \(index)") .frame(height: 50) }
if isLoading { ProgressView() .frame(height: 50) } } } .onAdditionalLoading( leadingScreens: 2, // Trigger when 2 screen heights from bottom isLoading: $isLoading, onLoad: { // This runs automatically when user scrolls near the end let lastItem = items.last ?? -1 let newItems = Array((lastItem + 1)..<(lastItem + 20)) items.append(contentsOf: newItems) } ) } }
// Also works with List struct InfiniteList: View { @State private var items = Array(0..<20) @State private var isLoading = false
var body: some View { List(items, id: \.self) { index in Text("Item \(index)") } .onAdditionalLoading( isLoading: $isLoading, onLoad: { let lastItem = items.last ?? -1 let newItems = Array((lastItem + 1)..<(lastItem + 20)) items.append(contentsOf: newItems) } ) } }
// Manual loading state management variant struct ManualInfiniteScrollView: View { @State private var items = Array(0..<20) @State private var isLoading = false
var body: some View { ScrollView { LazyVStack { ForEach(items, id: \.self) { index in Text("Item \(index)") .frame(height: 50) } } } .onAdditionalLoading( isLoading: isLoading, // Pass current value (not binding) onLoad: { guard !isLoading else { return } isLoading = true Task { // Your async loading logic here defer { isLoading = false } let newItems = await fetchMoreItems() items.append(contentsOf: newItems) } } ) } }
#### Parameters:
- `isEnabled`: Toggles the behavior on/off (default: true)
- `leadingScreens`: Trigger threshold in multiples of screen height (default: 2)
- `isLoading`: Loading state binding or current value
- `onLoad`: Closure executed when loading should occur
#### Features:
- Works with both `ScrollView` and `List`
- Automatic loading state management with binding variant
- Manual loading state management with non-binding variant
- Configurable trigger threshold
- Prevents duplicate loads while one is in progress
- Handles small content (triggers immediately if content is smaller than viewport)
### 5. StickyHeader
Implements sticky header behavior with stretching effect for ScrollView.
import StickyHeader
struct StickyHeaderView: View { var body: some View { ScrollView { StickyHeader(sizing: .content) { context in VStack { Image("header-image") .resizable() .aspectRatio(contentMode: .fill) .frame(height: 200 + context.stretchingValue)
Text("Stretching: \(context.phase == .stretching ? "Yes" : "No")") .padding() } }
LazyVStack { ForEach(items) { item in ItemView(item: item) } } } } }
#### Fixed Height Header:
StickyHeader(sizing: .fixed(250)) { context in HeaderContent() .scaleEffect(1 + context.stretchingValue / 100) }
### 6. PullingControl
A two-layered pull gesture detection system for ScrollView. Provides both low-level pull detection (`PullingControl`) and high-level pull-to-refresh functionality (`RefreshControl`).
#### Low-Level: PullingControl
Detects pull gestures and provides pull distance, progress, and threshold state. Use this when you need custom pull-based interactions beyond refresh.
import PullingControl
struct CustomPullView: View { @State private var log: [String] =
var body: some View { ScrollView { VStack(spacing: 0) { PullingControl( threshold: 80, onChange: { context in // Called when pull state changes if context.isThresholdReached { log.append("Threshold reached!") } } ) { context in if context.isPulling { VStack { Text("Pull progress: \(Int(context.progress * 100))%") Text(context.isThresholdReached ? "Release!" : "Keep pulling") } .padding() } }
LazyVStack { ForEach(log, id: \.self) { message in Text(message) } } } } } }
**PullingContext Properties:**
- `pullDistance: CGFloat` - Current pull distance in points
- `progress: Double` - Normalized progress (0.0 to 1.0)
- `isThresholdReached: Bool` - Whether threshold is reached
- `isPulling: Bool` - Whether currently pulling
#### High-Level: RefreshControl
Built on top of `PullingControl` with async action execution, refreshing state management, and haptic feedback.
import PullingControl
struct RefreshableList: View { @State private var items: [Item] =
var body: some View { ScrollView { VStack(spacing: 0) { RefreshControl( threshold: 80, action: { await refreshData() } ) { context in VStack { switch context.state { case .pulling(let progress): Image(systemName: "arrow.down") .rotationEffect(.degrees(progress * 180)) if progress >= 1.0 { Text("Release to refresh") } case .refreshing: ProgressView() Text("Refreshing...") default: EmptyView() } } .padding() }
LazyVStack { ForEach(items) { item in ItemView(item: item) } } } } }
func refreshData() async { // Fetch new data try? await Task.sleep(for: .seconds(1)) items = await fetchLatestItems() } }
**RefreshControlContext States:**
- `.idle` - Not pulling
- `.pulling(progress: Double)` - User is pulling
- `.refreshing` - Refresh action is executing
- `.finishing` - Refresh completed (optional for animations)
**When to Use Which:**
- **PullingControl**: Custom pull-based interactions, analytics, custom state management
- **RefreshControl**: Standard pull-to-refresh functionalityArchitecture Comparison
| Module | Implementation | Use Case | |--------|---------------|----------| | DynamicList | UIKit UICollectionView with SwiftUI hosting | Maximum performance, large datasets, complex layouts | | CollectionView | Pure SwiftUI (ScrollView + Lazy stacks) | Simple layouts, moderate datasets, pure SwiftUI apps | | SelectableForEach | Pure SwiftUI with environment values | Add selection to any container view | | ScrollTracking | SwiftUI with introspection | Infinite scrolling, additional content loading | | StickyHeader | Pure SwiftUI with geometry tracking | Sticky headers with stretching effects | | PullingControl | Pure SwiftUI with geometry tracking | Pull-to-refresh and custom pull gestures |
License
Licensed under the Apache License, Version 2.0. See the LICENSE file for more info.
Package Metadata
Repository: fluidgroup/swiftui-list-support
Default branch: main
README: README.md