Contents

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:

  1. Go to File → Add Package Dependencies
  2. Enter the repository URL: https://github.com/FluidGroup/swiftui-list-support.git
  3. 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 functionality

Architecture 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.

Author

FluidGroup

Package Metadata

Repository: fluidgroup/swiftui-list-support

Default branch: main

README: README.md