diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 0000000..2675c5b
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,82 @@
+name: Deploy Documentation
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Build Documentation
+ run: |
+ xcodebuild docbuild \
+ -scheme DiffableUI \
+ -derivedDataPath .build \
+ -destination 'platform=iOS Simulator,name=iPhone 16'
+
+ - name: Process Documentation Archive
+ run: |
+ DOCC_PATH="$(xcode-select -p)/usr/bin/docc"
+ "$DOCC_PATH" process-archive \
+ transform-for-static-hosting .build/Build/Products/Debug/DiffableUI.doccarchive \
+ --hosting-base-path /DiffableUI \
+ --output-path docs-website
+
+ - name: Create index redirect
+ run: |
+ cat > docs-website/index.html << 'EOF'
+
+
+
+
+
+ DiffableUI Documentation
+
+
+ Redirecting to DiffableUI Documentation...
+
+
+ EOF
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ./docs-website
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..8bd9c99
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,81 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+DiffableUI is a Swift Package that provides a SwiftUI-like declarative API for building UIKit collection views using UICollectionViewCompositionalLayout and UICollectionViewDiffableDataSource. It targets iOS 14+ and is built with Swift 5.9.
+
+## Key Architecture Concepts
+
+### Core Protocols
+- **CollectionItem**: Protocol for items that can be displayed in collection views. Items must be Hashable and provide a cell registration.
+- **CollectionSection**: Protocol for sections containing collection items. Sections handle their own layout configuration.
+- **CollectionView**: The main component that wraps UICollectionView with a declarative API and result builders.
+
+### Result Builders
+The library uses `@CollectionViewBuilder` to enable SwiftUI-like syntax for building collection view content declaratively.
+
+### Extension Pattern
+The codebase extensively uses protocol extensions to add functionality like `.onTap`, `.padding`, `.swipeActions` etc., mimicking SwiftUI's modifier pattern.
+
+## Development Commands
+
+```bash
+# Build the library
+swift build
+
+# Open the example project
+open Examples/HackerNews/HackerNews.xcodeproj
+
+# Build via GitHub Actions (automatically triggered on push)
+# See .github/workflows/build.yml
+```
+
+Note: Tests are currently commented out in Package.swift. When implementing tests, uncomment the test target and use `swift test`.
+
+## Code Organization
+
+- `Sources/DiffableUI/`: Main library code
+ - `Items/`: Pre-built collection item types (Text, Button, ActivityIndicator, etc.)
+ - `Sections/`: Section implementations (ListSection, GridSection, CarouselSection, etc.)
+ - `UI/`: UI components and collection view implementation
+ - `Protocols/`: Core protocol definitions
+ - `Extensions/`: SwiftUI-style modifier extensions
+
+- `Examples/HackerNews/`: Example app demonstrating library usage
+ - Shows pagination, loading states, and real API integration
+ - Good reference for implementing custom items and sections
+
+## Important Implementation Notes
+
+1. **Custom Items**: When creating custom collection items, ensure they:
+ - Conform to `CollectionItem` protocol
+ - Are `Hashable` (usually by including a unique identifier)
+ - Provide proper cell registration via `cellRegistration` property
+
+2. **Diffable Data Source**: The library handles diffing automatically. Items must be truly unique (proper Hashable implementation) to avoid animation issues.
+
+3. **Layout**: Each section type provides its own compositional layout. Custom sections should implement the `layout(environment:)` method.
+
+4. **SwiftUI-like Modifiers**: When adding new modifiers, follow the existing pattern of creating protocol extensions that return modified versions of items.
+
+## Common Tasks
+
+### Adding a New Item Type
+1. Create a new struct conforming to `CollectionItem`
+2. Implement required properties and cell registration
+3. Add any custom modifiers as protocol extensions
+4. See `Sources/DiffableUI/Items/Text.swift` for reference
+
+### Adding a New Section Type
+1. Create a new struct conforming to `CollectionSection`
+2. Implement the layout configuration
+3. Handle any special behaviors (like headers/footers)
+4. See `Sources/DiffableUI/Sections/ListSection.swift` for reference
+
+### Debugging Collection View Issues
+- Check that items have unique hash values
+- Verify cell registrations are correct
+- Use `.allowsSelection` and `.deselectOnSelection` modifiers for selection behavior
+- For performance issues, check if sections are properly implementing compositional layouts
\ No newline at end of file
diff --git a/Sources/DiffableUI/CollectionItem.swift b/Sources/DiffableUI/CollectionItem.swift
index aad0242..1f50428 100644
--- a/Sources/DiffableUI/CollectionItem.swift
+++ b/Sources/DiffableUI/CollectionItem.swift
@@ -9,16 +9,102 @@
import Foundation
import UIKit
+/// A type that can be displayed in a collection view.
+///
+/// Types conforming to `CollectionItem` represent individual items that can be displayed
+/// in a ``CollectionView``. Each item is responsible for configuring its own cell,
+/// handling selection, and providing a unique identifier.
+///
+/// ## Conforming to CollectionItem
+///
+/// To create a custom collection item, define a type that conforms to `CollectionItem`
+/// and implement the required properties and methods:
+///
+/// ```swift
+/// struct CustomItem: CollectionItem {
+/// typealias CellType = CustomCell
+/// typealias ItemType = String
+///
+/// let id = UUID()
+/// let item: String
+/// let reuseIdentifier = "CustomCell"
+///
+/// func configure(cell: CustomCell) {
+/// cell.label.text = item
+/// }
+/// }
+/// ```
+///
+/// ## Topics
+///
+/// ### Required Properties
+///
+/// - ``id``
+/// - ``item``
+/// - ``cellClass``
+/// - ``reuseIdentifier``
+///
+/// ### Configuration
+///
+/// - ``configure(cell:)``
+/// - ``setBehaviors(cell:)``
+///
+/// ### Lifecycle
+///
+/// - ``didSelect()``
+/// - ``willDisplay()``
public protocol CollectionItem: Equatable, Hashable, Identifiable {
+ /// The type of cell used to display this item.
associatedtype CellType: UICollectionViewCell
+
+ /// The type of the underlying data this item represents.
associatedtype ItemType: Hashable & Equatable
+
+ /// A unique identifier for this item.
+ ///
+ /// This identifier is used by the diffable data source to track items
+ /// and perform efficient updates.
var id: AnyHashable { get }
+
+ /// The underlying data this item represents.
var item: ItemType { get }
+
+ /// The class of the cell used to display this item.
var cellClass: CellType.Type { get }
+
+ /// The reuse identifier for the cell.
+ ///
+ /// This identifier is used to register and dequeue cells from the collection view.
var reuseIdentifier: String { get }
+
+ /// Configures the cell with this item's data.
+ ///
+ /// This method is called each time a cell needs to be configured for display.
+ /// Implement this method to update the cell's UI with the item's data.
+ ///
+ /// - Parameter cell: The cell to configure.
func configure(cell: CellType)
+
+ /// Called when the item is selected.
+ ///
+ /// The default implementation does nothing. Override this method to handle
+ /// selection events for your item.
func didSelect()
+
+ /// Sets up behaviors for the cell.
+ ///
+ /// This method is called once when the cell is first created. Use it to set up
+ /// gesture recognizers, observers, or other behaviors that should persist
+ /// across cell reuse.
+ ///
+ /// - Parameter cell: The cell to set up behaviors for.
func setBehaviors(cell: CellType)
+
+ /// Called when the item will be displayed.
+ ///
+ /// The default implementation does nothing. Override this method to perform
+ /// actions when the item is about to be displayed, such as starting animations
+ /// or loading data.
func willDisplay()
}
@@ -26,12 +112,16 @@ public protocol CollectionItem: Equatable, Hashable, Identifiable {
extension CollectionItem {
+ /// Default implementation does nothing.
public func didSelect() {}
+ /// Default implementation does nothing.
public func setBehaviors(cell: CellType) {}
+ /// Default implementation does nothing.
public func willDisplay() {}
+ /// Default implementation returns `CellType.self`.
public var cellClass: CellType.Type {
CellType.self
}
diff --git a/Sources/DiffableUI/CollectionSection.swift b/Sources/DiffableUI/CollectionSection.swift
index 7ddb965..73678a0 100644
--- a/Sources/DiffableUI/CollectionSection.swift
+++ b/Sources/DiffableUI/CollectionSection.swift
@@ -9,9 +9,72 @@
import Foundation
import UIKit
+/// A type that represents a section in a collection view.
+///
+/// Types conforming to `CollectionSection` define how a group of ``CollectionItem``
+/// instances are laid out in a collection view. Each section provides its own
+/// compositional layout configuration.
+///
+/// ## Conforming to CollectionSection
+///
+/// To create a custom section, define a type that conforms to `CollectionSection`
+/// and implement the required properties and methods:
+///
+/// ```swift
+/// struct CustomSection: CollectionSection {
+/// let id = UUID()
+/// let items: [any CollectionItem]
+///
+/// func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+/// let itemSize = NSCollectionLayoutSize(
+/// widthDimension: .fractionalWidth(1.0),
+/// heightDimension: .estimated(44)
+/// )
+/// let item = NSCollectionLayoutItem(layoutSize: itemSize)
+///
+/// let groupSize = NSCollectionLayoutSize(
+/// widthDimension: .fractionalWidth(1.0),
+/// heightDimension: .estimated(44)
+/// )
+/// let group = NSCollectionLayoutGroup.horizontal(
+/// layoutSize: groupSize,
+/// subitems: [item]
+/// )
+///
+/// return NSCollectionLayoutSection(group: group)
+/// }
+/// }
+/// ```
+///
+/// ## Topics
+///
+/// ### Required Properties
+///
+/// - ``id``
+/// - ``items``
+///
+/// ### Layout
+///
+/// - ``layout(environment:)``
public protocol CollectionSection: Equatable {
+ /// A unique identifier for this section.
+ ///
+ /// This identifier is used by the diffable data source to track sections
+ /// and perform efficient updates.
var id: AnyHashable { get }
+
+ /// The items contained in this section.
var items: [any CollectionItem] { get }
+
+ /// Creates the compositional layout for this section.
+ ///
+ /// This method is called when the collection view needs to determine how to
+ /// lay out the items in this section. The layout environment provides information
+ /// about the current trait collection and container size.
+ ///
+ /// - Parameter environment: The layout environment containing trait collection
+ /// and container information.
+ /// - Returns: A compositional layout section configuration.
func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection
}
@@ -25,7 +88,12 @@ extension CollectionSection {
}
}
+/// Extension providing snapshot generation for arrays of sections.
extension Array where Element == any CollectionSection {
+ /// Generates a diffable data source snapshot from the sections.
+ ///
+ /// This property creates a snapshot that can be applied to a diffable data source,
+ /// mapping sections and their items to the appropriate identifiers.
var snapshot: NSDiffableDataSourceSnapshot {
var diffableSnapshot = NSDiffableDataSourceSnapshot()
self.forEach { section in
diff --git a/Sources/DiffableUI/DiffableUI.docc/BuildingANewsFeed.md b/Sources/DiffableUI/DiffableUI.docc/BuildingANewsFeed.md
new file mode 100644
index 0000000..afed95e
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/BuildingANewsFeed.md
@@ -0,0 +1,498 @@
+# Building a News Feed
+
+Learn how to build a complete news feed with pagination, loading states, and dynamic content.
+
+## Overview
+
+This tutorial walks through building a Hacker News-style feed application, demonstrating key DiffableUI concepts including pagination, async data loading, error handling, and performance optimization.
+
+## Setting Up the Data Model
+
+First, let's define our data structures:
+
+```swift
+struct Story: Identifiable, Codable {
+ let id: Int
+ let title: String
+ let by: String
+ let score: Int
+ let time: Int
+ let url: String?
+ let descendants: Int
+
+ var timeAgo: String {
+ let date = Date(timeIntervalSince1970: TimeInterval(time))
+ let formatter = RelativeDateTimeFormatter()
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
+}
+
+enum FeedState {
+ case loading
+ case loaded([Story])
+ case error(Error)
+ case loadingMore([Story])
+}
+```
+
+## Creating the Feed View Controller
+
+Build the main feed view controller:
+
+```swift
+class NewsFeedViewController: DiffableViewController {
+ @State private var feedState = FeedState.loading
+ @State private var currentPage = 0
+ private let pageSize = 20
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ title = "Hacker News"
+ navigationController?.navigationBar.prefersLargeTitles = true
+
+ loadStories()
+ }
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ switch feedState {
+ case .loading:
+ LoadingSection()
+
+ case .loaded(let stories), .loadingMore(let stories):
+ StorySection(stories: stories)
+
+ if case .loadingMore = feedState {
+ LoadingMoreSection()
+ }
+
+ case .error(let error):
+ ErrorSection(error: error) {
+ self.loadStories()
+ }
+ }
+ }
+}
+```
+
+## Implementing Story Items
+
+Create a custom item for displaying stories:
+
+```swift
+struct StoryItem: CollectionItem {
+ typealias CellType = StoryCell
+ typealias ItemType = Story
+
+ let id: Int
+ let item: Story
+ let reuseIdentifier = "StoryCell"
+ var onTap: (() -> Void)?
+
+ init(story: Story, onTap: (() -> Void)? = nil) {
+ self.id = story.id
+ self.item = story
+ self.onTap = onTap
+ }
+
+ func configure(cell: StoryCell) {
+ cell.configure(with: item)
+ }
+
+ func didSelect() {
+ onTap?()
+ }
+}
+
+class StoryCell: UICollectionViewCell {
+ private let titleLabel = UILabel()
+ private let metadataLabel = UILabel()
+ private let scoreLabel = UILabel()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setupUI() {
+ titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
+ titleLabel.numberOfLines = 2
+
+ metadataLabel.font = .systemFont(ofSize: 12)
+ metadataLabel.textColor = .secondaryLabel
+
+ scoreLabel.font = .systemFont(ofSize: 14, weight: .semibold)
+ scoreLabel.textColor = .systemOrange
+
+ let stackView = UIStackView(arrangedSubviews: [
+ titleLabel,
+ metadataLabel,
+ scoreLabel
+ ])
+ stackView.axis = .vertical
+ stackView.spacing = 4
+
+ contentView.addSubview(stackView)
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
+ stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
+ stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
+ stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12)
+ ])
+ }
+
+ func configure(with story: Story) {
+ titleLabel.text = story.title
+ metadataLabel.text = "by \(story.by) • \(story.timeAgo) • \(story.descendants) comments"
+ scoreLabel.text = "▲ \(story.score)"
+ }
+}
+```
+
+## Building Section Types
+
+### Story Section
+
+```swift
+struct StorySection: CollectionSection {
+ let id = "stories"
+ let items: [any CollectionItem]
+
+ init(stories: [Story]) {
+ self.items = stories.map { story in
+ StoryItem(story: story) {
+ // Handle story tap
+ if let url = story.url, let url = URL(string: url) {
+ UIApplication.shared.open(url)
+ }
+ }
+ }
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(100)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(100)
+ )
+ let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
+
+ let section = NSCollectionLayoutSection(group: group)
+ section.interGroupSpacing = 1 // Separator line
+ section.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 0, bottom: 1, trailing: 0)
+
+ return section
+ }
+}
+```
+
+### Loading Section
+
+```swift
+struct LoadingSection: CollectionSection {
+ let id = "loading"
+ let items: [any CollectionItem]
+
+ init() {
+ self.items = [
+ ActivityIndicator()
+ .centerAligned()
+ .padding(.vertical, 100)
+ ]
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(200)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(200)
+ )
+ let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
+
+ return NSCollectionLayoutSection(group: group)
+ }
+}
+```
+
+## Implementing Data Loading
+
+### API Service
+
+```swift
+class HackerNewsAPI {
+ static let shared = HackerNewsAPI()
+ private let baseURL = "https://hacker-news.firebaseio.com/v0"
+
+ func fetchTopStories() async throws -> [Int] {
+ let url = URL(string: "\(baseURL)/topstories.json")!
+ let (data, _) = try await URLSession.shared.data(from: url)
+ return try JSONDecoder().decode([Int].self, from: data)
+ }
+
+ func fetchStory(id: Int) async throws -> Story {
+ let url = URL(string: "\(baseURL)/item/\(id).json")!
+ let (data, _) = try await URLSession.shared.data(from: url)
+ return try JSONDecoder().decode(Story.self, from: data)
+ }
+
+ func fetchStories(ids: [Int]) async throws -> [Story] {
+ try await withThrowingTaskGroup(of: Story?.self) { group in
+ for id in ids {
+ group.addTask {
+ try? await self.fetchStory(id: id)
+ }
+ }
+
+ var stories: [Story] = []
+ for try await story in group {
+ if let story = story {
+ stories.append(story)
+ }
+ }
+
+ // Sort by original order
+ return stories.sorted { first, second in
+ ids.firstIndex(of: first.id) ?? 0 < ids.firstIndex(of: second.id) ?? 0
+ }
+ }
+ }
+}
+```
+
+### Loading Stories
+
+```swift
+extension NewsFeedViewController {
+ private func loadStories() {
+ Task {
+ do {
+ feedState = .loading
+ reload()
+
+ let storyIDs = try await HackerNewsAPI.shared.fetchTopStories()
+ let pageIDs = Array(storyIDs.prefix(pageSize))
+ let stories = try await HackerNewsAPI.shared.fetchStories(ids: pageIDs)
+
+ feedState = .loaded(stories)
+ reload()
+ } catch {
+ feedState = .error(error)
+ reload()
+ }
+ }
+ }
+
+ private func loadMoreStories() {
+ guard case .loaded(let currentStories) = feedState else { return }
+
+ Task {
+ do {
+ feedState = .loadingMore(currentStories)
+ reload()
+
+ let storyIDs = try await HackerNewsAPI.shared.fetchTopStories()
+ let startIndex = (currentPage + 1) * pageSize
+ let endIndex = min(startIndex + pageSize, storyIDs.count)
+
+ guard startIndex < storyIDs.count else {
+ feedState = .loaded(currentStories)
+ reload()
+ return
+ }
+
+ let pageIDs = Array(storyIDs[startIndex..= stories.count - 5 {
+ loadMoreStories()
+ }
+ }
+}
+```
+
+## Pull to Refresh
+
+Add pull-to-refresh functionality:
+
+```swift
+override func viewDidLoad() {
+ super.viewDidLoad()
+
+ // Add refresh control
+ let refreshControl = UIRefreshControl()
+ refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
+ collectionView.refreshControl = refreshControl
+}
+
+@objc private func refresh() {
+ currentPage = 0
+
+ Task {
+ await loadStories()
+ collectionView.refreshControl?.endRefreshing()
+ }
+}
+```
+
+## Error Handling
+
+Create an error section:
+
+```swift
+struct ErrorSection: CollectionSection {
+ let id = "error"
+ let items: [any CollectionItem]
+
+ init(error: Error, retry: @escaping () -> Void) {
+ self.items = [
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 48))
+ .foregroundColor(.systemRed)
+
+ Text("Failed to load stories")
+ .font(.headline)
+
+ Text(error.localizedDescription)
+ .font(.caption)
+ .foregroundColor(.secondaryLabel)
+ .multilineTextAlignment(.center)
+
+ Button("Try Again") {
+ retry()
+ }
+ .foregroundColor(.systemBlue)
+ }
+ .padding(32)
+ .centerAligned()
+ ]
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ // Full screen layout
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .fractionalHeight(1.0)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .fractionalHeight(0.8)
+ )
+ let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
+
+ return NSCollectionLayoutSection(group: group)
+ }
+}
+```
+
+## Performance Optimizations
+
+### Image Caching
+
+If your feed includes images:
+
+```swift
+class ImageCache {
+ static let shared = ImageCache()
+ private let cache = NSCache()
+
+ func image(for url: URL) async throws -> UIImage {
+ let key = url.absoluteString as NSString
+
+ if let cached = cache.object(forKey: key) {
+ return cached
+ }
+
+ let (data, _) = try await URLSession.shared.data(from: url)
+ guard let image = UIImage(data: data) else {
+ throw ImageError.invalidData
+ }
+
+ cache.setObject(image, forKey: key)
+ return image
+ }
+}
+```
+
+### Prefetching
+
+Implement data prefetching:
+
+```swift
+class NewsFeedViewController: DiffableViewController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ collectionView.prefetchDataSource = self
+ }
+}
+
+extension NewsFeedViewController: UICollectionViewDataSourcePrefetching {
+ func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
+ // Prefetch images or data for upcoming cells
+ }
+}
+```
+
+## Complete Example
+
+The complete implementation is available in the [HackerNews example project](https://github.com/loloop/DiffableUI/tree/main/Examples/HackerNews).
+
+## Key Takeaways
+
+1. **State Management**: Use enums to represent different feed states
+2. **Async Loading**: Leverage Swift concurrency for clean async code
+3. **Error Handling**: Always provide retry mechanisms
+4. **Performance**: Implement caching and prefetching for smooth scrolling
+5. **User Experience**: Add loading indicators and pull-to-refresh
+
+## Next Steps
+
+- Add search functionality
+- Implement comment threads
+- Add offline support with Core Data
+- Create custom transitions between screens
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/CreatingAPhotoGrid.md b/Sources/DiffableUI/DiffableUI.docc/CreatingAPhotoGrid.md
new file mode 100644
index 0000000..540eda4
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/CreatingAPhotoGrid.md
@@ -0,0 +1,565 @@
+# Creating a Photo Grid
+
+Build a responsive photo grid with selection, zooming, and sharing capabilities.
+
+## Overview
+
+This tutorial demonstrates how to create a photo grid similar to the iOS Photos app, featuring responsive layouts, multi-selection, batch operations, and smooth animations.
+
+## Setting Up the Photo Model
+
+Define the photo data structure:
+
+```swift
+struct Photo: Identifiable {
+ let id = UUID()
+ let image: UIImage
+ let thumbnail: UIImage
+ let createdAt: Date
+ let location: String?
+
+ static func generateThumbnail(from image: UIImage, size: CGSize) -> UIImage {
+ let renderer = UIGraphicsImageRenderer(size: size)
+ return renderer.image { _ in
+ image.draw(in: CGRect(origin: .zero, size: size))
+ }
+ }
+}
+
+struct Album {
+ let id = UUID()
+ let name: String
+ var photos: [Photo]
+ let coverPhoto: Photo?
+}
+```
+
+## Creating the Grid Layout
+
+Build an adaptive grid that responds to device orientation:
+
+```swift
+struct PhotoGridSection: CollectionSection {
+ let id = "photo-grid"
+ let photos: [Photo]
+ let selectedIDs: Set
+ let onPhotoTap: (Photo) -> Void
+ let onPhotoLongPress: (Photo) -> Void
+
+ var items: [any CollectionItem] {
+ photos.map { photo in
+ PhotoGridItem(
+ photo: photo,
+ isSelected: selectedIDs.contains(photo.id),
+ onTap: { onPhotoTap(photo) },
+ onLongPress: { onPhotoLongPress(photo) }
+ )
+ }
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ // Calculate columns based on container width
+ let containerWidth = environment.container.contentSize.width
+ let minItemWidth: CGFloat = 100
+ let spacing: CGFloat = 2
+
+ let columns = max(1, Int(containerWidth / minItemWidth))
+ let itemWidth = (containerWidth - (CGFloat(columns - 1) * spacing)) / CGFloat(columns)
+
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .absolute(itemWidth),
+ heightDimension: .absolute(itemWidth) // Square items
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(itemWidth)
+ )
+ let group = NSCollectionLayoutGroup.horizontal(
+ layoutSize: groupSize,
+ subitem: item,
+ count: columns
+ )
+ group.interItemSpacing = .fixed(spacing)
+
+ let section = NSCollectionLayoutSection(group: group)
+ section.interGroupSpacing = spacing
+
+ return section
+ }
+}
+```
+
+## Photo Grid Item
+
+Create a custom item for photos with selection support:
+
+```swift
+struct PhotoGridItem: CollectionItem {
+ typealias CellType = PhotoGridCell
+ typealias ItemType = Photo
+
+ let id: UUID
+ let item: Photo
+ let isSelected: Bool
+ let reuseIdentifier = "PhotoGridCell"
+ let onTap: () -> Void
+ let onLongPress: () -> Void
+
+ init(photo: Photo, isSelected: Bool, onTap: @escaping () -> Void, onLongPress: @escaping () -> Void) {
+ self.id = photo.id
+ self.item = photo
+ self.isSelected = isSelected
+ self.onTap = onTap
+ self.onLongPress = onLongPress
+ }
+
+ func configure(cell: PhotoGridCell) {
+ cell.configure(with: item, isSelected: isSelected)
+ }
+
+ func didSelect() {
+ onTap()
+ }
+
+ func setBehaviors(cell: PhotoGridCell) {
+ let longPress = UILongPressGestureRecognizer(
+ target: cell,
+ action: #selector(cell.handleLongPress)
+ )
+ longPress.minimumPressDuration = 0.5
+ cell.addGestureRecognizer(longPress)
+ cell.onLongPress = onLongPress
+ }
+}
+
+class PhotoGridCell: UICollectionViewCell {
+ private let imageView = UIImageView()
+ private let selectionOverlay = UIView()
+ private let checkmarkImageView = UIImageView()
+ var onLongPress: (() -> Void)?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setupUI() {
+ // Image view
+ imageView.contentMode = .scaleAspectFill
+ imageView.clipsToBounds = true
+
+ // Selection overlay
+ selectionOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.3)
+ selectionOverlay.alpha = 0
+
+ // Checkmark
+ checkmarkImageView.image = UIImage(systemName: "checkmark.circle.fill")
+ checkmarkImageView.tintColor = .white
+ checkmarkImageView.alpha = 0
+
+ // Layout
+ [imageView, selectionOverlay, checkmarkImageView].forEach {
+ contentView.addSubview($0)
+ $0.translatesAutoresizingMaskIntoConstraints = false
+ }
+
+ NSLayoutConstraint.activate([
+ imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+
+ selectionOverlay.topAnchor.constraint(equalTo: contentView.topAnchor),
+ selectionOverlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ selectionOverlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ selectionOverlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+
+ checkmarkImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
+ checkmarkImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
+ checkmarkImageView.widthAnchor.constraint(equalToConstant: 24),
+ checkmarkImageView.heightAnchor.constraint(equalToConstant: 24)
+ ])
+ }
+
+ func configure(with photo: Photo, isSelected: Bool) {
+ imageView.image = photo.thumbnail
+
+ UIView.animate(withDuration: 0.2) {
+ self.selectionOverlay.alpha = isSelected ? 1 : 0
+ self.checkmarkImageView.alpha = isSelected ? 1 : 0
+ }
+ }
+
+ @objc func handleLongPress() {
+ onLongPress?()
+ }
+}
+```
+
+## Photo Grid View Controller
+
+Implement the main view controller with selection modes:
+
+```swift
+class PhotoGridViewController: DiffableViewController {
+ enum Mode {
+ case viewing
+ case selecting
+ }
+
+ @State private var photos: [Photo] = []
+ @State private var mode = Mode.viewing
+ @State private var selectedPhotoIDs = Set()
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ title = "Photos"
+ setupNavigationBar()
+ loadPhotos()
+ }
+
+ private func setupNavigationBar() {
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ title: "Select",
+ style: .plain,
+ target: self,
+ action: #selector(toggleSelectionMode)
+ )
+ }
+
+ @objc private func toggleSelectionMode() {
+ switch mode {
+ case .viewing:
+ enterSelectionMode()
+ case .selecting:
+ exitSelectionMode()
+ }
+ }
+
+ private func enterSelectionMode() {
+ mode = .selecting
+ selectedPhotoIDs.removeAll()
+
+ navigationItem.rightBarButtonItem?.title = "Cancel"
+ navigationItem.leftBarButtonItem = UIBarButtonItem(
+ title: "Select All",
+ style: .plain,
+ target: self,
+ action: #selector(selectAll)
+ )
+
+ updateToolbar()
+ reload()
+ }
+
+ private func exitSelectionMode() {
+ mode = .viewing
+ selectedPhotoIDs.removeAll()
+
+ navigationItem.rightBarButtonItem?.title = "Select"
+ navigationItem.leftBarButtonItem = nil
+ navigationController?.setToolbarHidden(true, animated: true)
+
+ reload()
+ }
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ PhotoGridSection(
+ photos: photos,
+ selectedIDs: selectedPhotoIDs,
+ onPhotoTap: { [weak self] photo in
+ self?.handlePhotoTap(photo)
+ },
+ onPhotoLongPress: { [weak self] photo in
+ self?.handlePhotoLongPress(photo)
+ }
+ )
+ }
+}
+```
+
+## Selection Handling
+
+Implement photo selection logic:
+
+```swift
+extension PhotoGridViewController {
+ private func handlePhotoTap(_ photo: Photo) {
+ switch mode {
+ case .viewing:
+ showPhotoDetail(photo)
+ case .selecting:
+ togglePhotoSelection(photo)
+ }
+ }
+
+ private func handlePhotoLongPress(_ photo: Photo) {
+ guard mode == .viewing else { return }
+
+ // Enter selection mode and select the long-pressed photo
+ enterSelectionMode()
+ togglePhotoSelection(photo)
+ }
+
+ private func togglePhotoSelection(_ photo: Photo) {
+ if selectedPhotoIDs.contains(photo.id) {
+ selectedPhotoIDs.remove(photo.id)
+ } else {
+ selectedPhotoIDs.insert(photo.id)
+ }
+
+ updateToolbar()
+ reload()
+ }
+
+ @objc private func selectAll() {
+ if selectedPhotoIDs.count == photos.count {
+ // Deselect all
+ selectedPhotoIDs.removeAll()
+ navigationItem.leftBarButtonItem?.title = "Select All"
+ } else {
+ // Select all
+ selectedPhotoIDs = Set(photos.map { $0.id })
+ navigationItem.leftBarButtonItem?.title = "Deselect All"
+ }
+
+ updateToolbar()
+ reload()
+ }
+}
+```
+
+## Toolbar Actions
+
+Add batch operations toolbar:
+
+```swift
+extension PhotoGridViewController {
+ private func updateToolbar() {
+ guard mode == .selecting else {
+ navigationController?.setToolbarHidden(true, animated: true)
+ return
+ }
+
+ let shareButton = UIBarButtonItem(
+ image: UIImage(systemName: "square.and.arrow.up"),
+ style: .plain,
+ target: self,
+ action: #selector(shareSelectedPhotos)
+ )
+ shareButton.isEnabled = !selectedPhotoIDs.isEmpty
+
+ let deleteButton = UIBarButtonItem(
+ image: UIImage(systemName: "trash"),
+ style: .plain,
+ target: self,
+ action: #selector(deleteSelectedPhotos)
+ )
+ deleteButton.isEnabled = !selectedPhotoIDs.isEmpty
+ deleteButton.tintColor = .systemRed
+
+ let flexibleSpace = UIBarButtonItem(
+ barButtonSystemItem: .flexibleSpace,
+ target: nil,
+ action: nil
+ )
+
+ let countLabel = UILabel()
+ countLabel.text = "\(selectedPhotoIDs.count) selected"
+ countLabel.font = .systemFont(ofSize: 16)
+ let countItem = UIBarButtonItem(customView: countLabel)
+
+ toolbarItems = [shareButton, flexibleSpace, countItem, flexibleSpace, deleteButton]
+ navigationController?.setToolbarHidden(false, animated: true)
+ }
+
+ @objc private func shareSelectedPhotos() {
+ let selectedPhotos = photos.filter { selectedPhotoIDs.contains($0.id) }
+ let images = selectedPhotos.map { $0.image }
+
+ let activityController = UIActivityViewController(
+ activityItems: images,
+ applicationActivities: nil
+ )
+ present(activityController, animated: true)
+ }
+
+ @objc private func deleteSelectedPhotos() {
+ let alert = UIAlertController(
+ title: "Delete \(selectedPhotoIDs.count) Photos?",
+ message: "This action cannot be undone.",
+ preferredStyle: .alert
+ )
+
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in
+ self.photos.removeAll { self.selectedPhotoIDs.contains($0.id) }
+ self.exitSelectionMode()
+ })
+
+ present(alert, animated: true)
+ }
+}
+```
+
+## Photo Detail View
+
+Create a detail view with zooming:
+
+```swift
+class PhotoDetailViewController: UIViewController {
+ private let photo: Photo
+ private let scrollView = UIScrollView()
+ private let imageView = UIImageView()
+
+ init(photo: Photo) {
+ self.photo = photo
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .black
+ setupScrollView()
+ setupImageView()
+ setupGestures()
+ }
+
+ private func setupScrollView() {
+ scrollView.delegate = self
+ scrollView.minimumZoomScale = 1.0
+ scrollView.maximumZoomScale = 4.0
+ scrollView.showsHorizontalScrollIndicator = false
+ scrollView.showsVerticalScrollIndicator = false
+
+ view.addSubview(scrollView)
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: view.topAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+ }
+
+ private func setupImageView() {
+ imageView.image = photo.image
+ imageView.contentMode = .scaleAspectFit
+
+ scrollView.addSubview(imageView)
+ imageView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ imageView.topAnchor.constraint(equalTo: scrollView.topAnchor),
+ imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
+ imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
+ imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
+ imageView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
+ imageView.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
+ ])
+ }
+
+ private func setupGestures() {
+ let doubleTap = UITapGestureRecognizer(
+ target: self,
+ action: #selector(handleDoubleTap(_:))
+ )
+ doubleTap.numberOfTapsRequired = 2
+ scrollView.addGestureRecognizer(doubleTap)
+ }
+
+ @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
+ if scrollView.zoomScale > 1 {
+ scrollView.setZoomScale(1, animated: true)
+ } else {
+ let point = gesture.location(in: imageView)
+ let rect = CGRect(x: point.x, y: point.y, width: 1, height: 1)
+ scrollView.zoom(to: rect, animated: true)
+ }
+ }
+}
+
+extension PhotoDetailViewController: UIScrollViewDelegate {
+ func viewForZooming(in scrollView: UIScrollView) -> UIView? {
+ return imageView
+ }
+}
+```
+
+## Performance Optimizations
+
+### Thumbnail Generation
+
+Generate thumbnails efficiently:
+
+```swift
+class PhotoThumbnailGenerator {
+ static let shared = PhotoThumbnailGenerator()
+ private let thumbnailCache = NSCache()
+
+ func thumbnail(for photo: Photo, size: CGSize) async -> UIImage {
+ let key = "\(photo.id)-\(size.width)x\(size.height)" as NSString
+
+ if let cached = thumbnailCache.object(forKey: key) {
+ return cached
+ }
+
+ let thumbnail = await generateThumbnail(from: photo.image, size: size)
+ thumbnailCache.setObject(thumbnail, forKey: key)
+
+ return thumbnail
+ }
+
+ private func generateThumbnail(from image: UIImage, size: CGSize) async -> UIImage {
+ await withCheckedContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ let renderer = UIGraphicsImageRenderer(size: size)
+ let thumbnail = renderer.image { _ in
+ image.draw(in: CGRect(origin: .zero, size: size))
+ }
+ continuation.resume(returning: thumbnail)
+ }
+ }
+ }
+}
+```
+
+### Memory Management
+
+Handle large photo collections:
+
+```swift
+class PhotoGridViewController: DiffableViewController {
+ override func didReceiveMemoryWarning() {
+ super.didReceiveMemoryWarning()
+ // Clear thumbnail cache
+ PhotoThumbnailGenerator.shared.clearCache()
+ }
+}
+```
+
+## Key Features Implemented
+
+1. **Responsive Grid**: Adapts to device orientation and screen size
+2. **Multi-Selection**: Long press to enter selection mode
+3. **Batch Operations**: Share and delete multiple photos
+4. **Photo Viewing**: Full-screen photo with pinch-to-zoom
+5. **Performance**: Efficient thumbnail generation and caching
+
+## Next Steps
+
+- Add photo filtering and sorting
+- Implement album organization
+- Add photo editing capabilities
+- Create custom transitions
+- Add iCloud Photo Library support
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/CreatingCustomItems.md b/Sources/DiffableUI/DiffableUI.docc/CreatingCustomItems.md
new file mode 100644
index 0000000..ed2fa06
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/CreatingCustomItems.md
@@ -0,0 +1,310 @@
+# Creating Custom Items
+
+Learn how to create reusable custom collection items for your specific needs.
+
+## Overview
+
+While DiffableUI provides many built-in items like `Text`, `Button`, and `ActivityIndicator`, you'll often need to create custom items for your specific UI requirements. This guide shows you how to create custom collection items that integrate seamlessly with DiffableUI.
+
+## Understanding CollectionItem
+
+Custom items must conform to the `CollectionItem` protocol:
+
+```swift
+public protocol CollectionItem: Equatable, Hashable, Identifiable {
+ associatedtype CellType: UICollectionViewCell
+ associatedtype ItemType: Hashable & Equatable
+
+ var id: AnyHashable { get }
+ var item: ItemType { get }
+ var cellClass: CellType.Type { get }
+ var reuseIdentifier: String { get }
+
+ func configure(cell: CellType)
+ func didSelect()
+ func setBehaviors(cell: CellType)
+ func willDisplay()
+}
+```
+
+## Creating a Simple Custom Item
+
+Let's create a custom profile item that displays a user's avatar and name:
+
+### Step 1: Create the Cell
+
+First, create a custom `UICollectionViewCell`:
+
+```swift
+class ProfileCell: UICollectionViewCell {
+ private let imageView = UIImageView()
+ private let nameLabel = UILabel()
+ private let stackView = UIStackView()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setupUI() {
+ // Configure image view
+ imageView.contentMode = .scaleAspectFill
+ imageView.clipsToBounds = true
+ imageView.layer.cornerRadius = 25
+ imageView.widthAnchor.constraint(equalToConstant: 50).isActive = true
+ imageView.heightAnchor.constraint(equalToConstant: 50).isActive = true
+
+ // Configure label
+ nameLabel.font = .systemFont(ofSize: 16, weight: .medium)
+
+ // Configure stack view
+ stackView.axis = .horizontal
+ stackView.spacing = 12
+ stackView.alignment = .center
+ stackView.addArrangedSubview(imageView)
+ stackView.addArrangedSubview(nameLabel)
+
+ // Add to cell
+ contentView.addSubview(stackView)
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
+ stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
+ stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
+ stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
+ ])
+ }
+
+ func configure(image: UIImage?, name: String) {
+ imageView.image = image
+ nameLabel.text = name
+ }
+}
+```
+
+### Step 2: Create the Item
+
+Now create the item that uses this cell:
+
+```swift
+struct ProfileItem: CollectionItem {
+ typealias CellType = ProfileCell
+ typealias ItemType = Profile
+
+ struct Profile: Hashable {
+ let name: String
+ let imageName: String
+ }
+
+ let id = UUID()
+ let item: Profile
+ let reuseIdentifier = "ProfileCell"
+
+ // Optional: Add action handling
+ var onTap: (() -> Void)?
+
+ init(name: String, imageName: String, onTap: (() -> Void)? = nil) {
+ self.item = Profile(name: name, imageName: imageName)
+ self.onTap = onTap
+ }
+
+ func configure(cell: ProfileCell) {
+ let image = UIImage(systemName: item.imageName)
+ cell.configure(image: image, name: item.name)
+ }
+
+ func didSelect() {
+ onTap?()
+ }
+}
+```
+
+### Step 3: Use Your Custom Item
+
+Use your custom item in a collection view:
+
+```swift
+class ContactsViewController: DiffableViewController {
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ ProfileItem(name: "John Doe", imageName: "person.fill") {
+ print("John's profile tapped")
+ }
+
+ ProfileItem(name: "Jane Smith", imageName: "person.fill")
+
+ ProfileItem(name: "Bob Johnson", imageName: "person.fill")
+ }
+ }
+}
+```
+
+## Advanced Custom Items
+
+### Adding Modifiers
+
+You can add SwiftUI-style modifiers to your custom items by creating extensions:
+
+```swift
+extension ProfileItem {
+ func disabled(_ isDisabled: Bool) -> Self {
+ var copy = self
+ // Store disabled state and apply in configure(cell:)
+ return copy
+ }
+
+ func badgeCount(_ count: Int) -> Self {
+ var copy = self
+ // Store badge count and display in cell
+ return copy
+ }
+}
+```
+
+### Handling State Changes
+
+For items that need to update based on state changes:
+
+```swift
+struct ToggleItem: CollectionItem {
+ typealias CellType = ToggleCell
+ typealias ItemType = ToggleState
+
+ struct ToggleState: Hashable {
+ let title: String
+ let isOn: Bool
+ }
+
+ let id: UUID
+ let item: ToggleState
+ let reuseIdentifier = "ToggleCell"
+ var onToggle: ((Bool) -> Void)?
+
+ init(title: String, isOn: Bool, onToggle: ((Bool) -> Void)? = nil) {
+ self.id = UUID()
+ self.item = ToggleState(title: title, isOn: isOn)
+ self.onToggle = onToggle
+ }
+
+ func configure(cell: ToggleCell) {
+ cell.configure(title: item.title, isOn: item.isOn)
+ }
+
+ func setBehaviors(cell: ToggleCell) {
+ cell.onToggle = { [weak self] isOn in
+ self?.onToggle?(isOn)
+ }
+ }
+}
+```
+
+### Creating Composite Items
+
+You can create items that contain other views:
+
+```swift
+struct CardItem: CollectionItem {
+ typealias CellType = CardCell
+ typealias ItemType = CardContent
+
+ struct CardContent: Hashable {
+ let title: String
+ let subtitle: String
+ let imageURL: URL?
+ }
+
+ let id = UUID()
+ let item: CardContent
+ let reuseIdentifier = "CardCell"
+
+ func configure(cell: CardCell) {
+ cell.configure(
+ title: item.title,
+ subtitle: item.subtitle,
+ imageURL: item.imageURL
+ )
+ }
+
+ func willDisplay() {
+ // Start loading image when cell will be displayed
+ ImageLoader.shared.preload(item.imageURL)
+ }
+}
+```
+
+## Best Practices
+
+### 1. Keep Items Lightweight
+
+Items should be value types (structs) that are cheap to create and copy:
+
+```swift
+// Good: Lightweight struct
+struct LabelItem: CollectionItem {
+ let id = UUID()
+ let text: String
+ // ...
+}
+
+// Avoid: Heavy reference types
+class HeavyItem: CollectionItem { // Don't do this
+ var largeData: Data
+ // ...
+}
+```
+
+### 2. Use Unique Identifiers
+
+Always ensure your items have truly unique identifiers:
+
+```swift
+struct MessageItem: CollectionItem {
+ let id: String // Use message ID from your backend
+ let message: Message
+
+ init(message: Message) {
+ self.id = message.id // Unique ID from data model
+ self.message = message
+ }
+}
+```
+
+### 3. Handle Cell Reuse Properly
+
+Always fully configure cells in `configure(cell:)` to handle cell reuse:
+
+```swift
+func configure(cell: MyCell) {
+ cell.titleLabel.text = item.title
+ cell.subtitleLabel.text = item.subtitle
+
+ // Reset optional states
+ cell.imageView.image = nil
+ cell.badgeView.isHidden = item.badgeCount == 0
+}
+```
+
+### 4. Separate Behaviors from Configuration
+
+Use `setBehaviors(cell:)` for one-time setup like gesture recognizers:
+
+```swift
+func setBehaviors(cell: MyCell) {
+ let longPress = UILongPressGestureRecognizer(
+ target: cell,
+ action: #selector(cell.handleLongPress)
+ )
+ cell.addGestureRecognizer(longPress)
+}
+```
+
+## Next Steps
+
+- Learn about to create custom layouts
+- Explore for advanced interaction patterns
+- See the example project for more complex custom items
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/CreatingCustomSections.md b/Sources/DiffableUI/DiffableUI.docc/CreatingCustomSections.md
new file mode 100644
index 0000000..fec021f
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/CreatingCustomSections.md
@@ -0,0 +1,352 @@
+# Creating Custom Sections
+
+Learn how to create custom section layouts for unique collection view designs.
+
+## Overview
+
+DiffableUI provides several built-in section types like `ListSection`, `GridSection`, and `CarouselSection`. However, you can create custom sections to achieve unique layouts using UIKit's compositional layout system.
+
+## Understanding CollectionSection
+
+Custom sections must conform to the `CollectionSection` protocol:
+
+```swift
+public protocol CollectionSection: Equatable {
+ var id: AnyHashable { get }
+ var items: [any CollectionItem] { get }
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection
+}
+```
+
+## Creating a Simple Custom Section
+
+Let's create a staggered grid section where items have varying heights:
+
+```swift
+struct StaggeredGridSection: CollectionSection {
+ let id = UUID()
+ let items: [any CollectionItem]
+ let columns: Int
+
+ init(columns: Int = 2, @CollectionItemBuilder content: () -> [any CollectionItem]) {
+ self.columns = columns
+ self.items = content()
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ // Create a group with multiple columns
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
+ heightDimension: .estimated(100) // Dynamic height
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+ item.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(100)
+ )
+
+ let group = NSCollectionLayoutGroup.horizontal(
+ layoutSize: groupSize,
+ subitem: item,
+ count: columns
+ )
+
+ let section = NSCollectionLayoutSection(group: group)
+ section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
+
+ return section
+ }
+}
+```
+
+## Advanced Layout Examples
+
+### Horizontal Scrolling Section
+
+Create a section that scrolls horizontally with paging:
+
+```swift
+struct HorizontalPagingSection: CollectionSection {
+ let id = UUID()
+ let items: [any CollectionItem]
+ let itemWidth: CGFloat
+
+ init(itemWidth: CGFloat = 0.8, @CollectionItemBuilder content: () -> [any CollectionItem]) {
+ self.itemWidth = itemWidth
+ self.items = content()
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(itemWidth),
+ heightDimension: .fractionalHeight(1.0)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+ item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(itemWidth),
+ heightDimension: .absolute(200)
+ )
+ let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
+
+ let section = NSCollectionLayoutSection(group: group)
+ section.orthogonalScrollingBehavior = .groupPagingCentered
+ section.interGroupSpacing = 0
+
+ // Add page control as supplementary view
+ section.visibleItemsInvalidationHandler = { items, contentOffset, environment in
+ let currentPage = Int(round(contentOffset.x / environment.container.contentSize.width * CGFloat(items.count)))
+ // Update page control
+ }
+
+ return section
+ }
+}
+```
+
+### Waterfall Layout Section
+
+Create a Pinterest-style waterfall layout:
+
+```swift
+struct WaterfallSection: CollectionSection {
+ let id = UUID()
+ let items: [any CollectionItem]
+ let columns: Int
+
+ init(columns: Int = 2, @CollectionItemBuilder content: () -> [any CollectionItem]) {
+ self.columns = columns
+ self.items = content()
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let configuration = UICollectionViewCompositionalLayoutConfiguration()
+ configuration.scrollDirection = .vertical
+
+ // Create custom layout with provider
+ let section = NSCollectionLayoutSection(group: createWaterfallGroup())
+ section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
+
+ return section
+ }
+
+ private func createWaterfallGroup() -> NSCollectionLayoutGroup {
+ // Implementation of waterfall algorithm
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
+ heightDimension: .estimated(150)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(150)
+ )
+
+ return NSCollectionLayoutGroup.horizontal(
+ layoutSize: groupSize,
+ subitem: item,
+ count: columns
+ )
+ }
+}
+```
+
+## Adding Headers and Footers
+
+Sections can include supplementary views like headers and footers:
+
+```swift
+struct SectionWithHeader: CollectionSection {
+ let id = UUID()
+ let items: [any CollectionItem]
+ let title: String
+
+ init(title: String, @CollectionItemBuilder content: () -> [any CollectionItem]) {
+ self.title = title
+ self.items = content()
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ // Create basic list layout
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(44)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(44)
+ )
+ let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
+
+ let section = NSCollectionLayoutSection(group: group)
+
+ // Add header
+ let headerSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(50)
+ )
+ let header = NSCollectionLayoutBoundarySupplementaryItem(
+ layoutSize: headerSize,
+ elementKind: UICollectionView.elementKindSectionHeader,
+ alignment: .top
+ )
+ header.pinToVisibleBounds = true // Sticky header
+
+ section.boundarySupplementaryItems = [header]
+
+ return section
+ }
+}
+```
+
+## Adaptive Layouts
+
+Create sections that adapt to different size classes:
+
+```swift
+struct AdaptiveGridSection: CollectionSection {
+ let id = UUID()
+ let items: [any CollectionItem]
+
+ init(@CollectionItemBuilder content: () -> [any CollectionItem]) {
+ self.items = content()
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ // Determine columns based on container width
+ let containerWidth = environment.container.contentSize.width
+ let columns: Int
+
+ switch containerWidth {
+ case 0..<400:
+ columns = 2
+ case 400..<600:
+ columns = 3
+ case 600..<800:
+ columns = 4
+ default:
+ columns = 5
+ }
+
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
+ heightDimension: .aspectRatio(1.0) // Square items
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+ item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .fractionalWidth(1.0 / CGFloat(columns))
+ )
+ let group = NSCollectionLayoutGroup.horizontal(
+ layoutSize: groupSize,
+ subitem: item,
+ count: columns
+ )
+
+ return NSCollectionLayoutSection(group: group)
+ }
+}
+```
+
+## Animating Section Changes
+
+Add custom animations to your sections:
+
+```swift
+struct AnimatedSection: CollectionSection {
+ let id = UUID()
+ let items: [any CollectionItem]
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let section = createBasicLayout()
+
+ // Add animation behavior
+ section.visibleItemsInvalidationHandler = { items, offset, environment in
+ items.forEach { item in
+ let distanceFromCenter = abs(item.center.x - (offset.x + environment.container.contentSize.width / 2))
+ let scale = max(0.8, 1 - (distanceFromCenter / environment.container.contentSize.width))
+ item.transform = CGAffineTransform(scaleX: scale, y: scale)
+ }
+ }
+
+ return section
+ }
+
+ private func createBasicLayout() -> NSCollectionLayoutSection {
+ // Your layout implementation
+ }
+}
+```
+
+## Best Practices
+
+### 1. Use Environment Information
+
+Always consider the layout environment when creating sections:
+
+```swift
+func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let isCompact = environment.traitCollection.horizontalSizeClass == .compact
+ let columns = isCompact ? 2 : 4
+ // Adjust layout based on trait collection
+}
+```
+
+### 2. Performance Considerations
+
+For large datasets, use estimated dimensions:
+
+```swift
+// Good: Allows dynamic sizing
+let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(100)
+)
+
+// Avoid for dynamic content
+let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(100) // Fixed height
+)
+```
+
+### 3. Reusable Section Components
+
+Create reusable section configurations:
+
+```swift
+extension NSCollectionLayoutSection {
+ static func list(spacing: CGFloat = 0) -> NSCollectionLayoutSection {
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(44)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .estimated(44)
+ )
+ let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
+
+ let section = NSCollectionLayoutSection(group: group)
+ section.interGroupSpacing = spacing
+
+ return section
+ }
+}
+```
+
+## Next Steps
+
+- Explore for adding gestures to sections
+- Learn about performance optimization in
+- See more examples in the [HackerNews sample app](https://github.com/loloop/DiffableUI/tree/main/Examples/HackerNews)
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/DiffableUI.md b/Sources/DiffableUI/DiffableUI.docc/DiffableUI.md
new file mode 100644
index 0000000..2eba0c9
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/DiffableUI.md
@@ -0,0 +1,62 @@
+# ``DiffableUI``
+
+A SwiftUI-like declarative API for building UIKit collection views with compositional layouts and diffable data sources.
+
+## Overview
+
+DiffableUI brings the simplicity and expressiveness of SwiftUI's declarative syntax to UIKit's powerful collection view system. Built on top of `UICollectionViewCompositionalLayout` and `UICollectionViewDiffableDataSource`, it provides a modern way to create dynamic, performant collection views while maintaining full UIKit compatibility.
+
+### Key Features
+
+- **Declarative Syntax**: Build collection views using SwiftUI-like syntax with result builders
+- **Type-Safe**: Leverage Swift's type system for compile-time safety
+- **Performant**: Built on UIKit's diffable data source for automatic, efficient updates
+- **Flexible Layouts**: Support for lists, grids, carousels, and custom compositional layouts
+- **Extensible**: Easy to create custom items and sections
+- **UIKit Integration**: Seamlessly integrates with existing UIKit code
+
+## Topics
+
+### Essentials
+
+-
+- ``CollectionView``
+- ``CollectionItem``
+- ``CollectionSection``
+
+### Building Collection Views
+
+- ``CollectionViewBuilder``
+- ``ListSection``
+- ``GridSection``
+- ``CarouselSection``
+- ``HStackSection``
+- ``PagingSection``
+
+### Common Items
+
+- ``Text``
+- ``Button``
+- ``ActivityIndicator``
+- ``Separator``
+- ``Space``
+- ``PageControl``
+
+### Advanced Items
+
+- ``LazyItem``
+- ``LoadingItem``
+- ``InteractiveItem``
+- ``SelectableItem``
+
+### Customization
+
+-
+-
+-
+
+### Examples
+
+-
+-
+-
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/GettingStarted.md b/Sources/DiffableUI/DiffableUI.docc/GettingStarted.md
new file mode 100644
index 0000000..f468e25
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/GettingStarted.md
@@ -0,0 +1,199 @@
+# Getting Started
+
+Learn how to integrate DiffableUI into your project and create your first collection view.
+
+## Overview
+
+DiffableUI provides a declarative, SwiftUI-like API for building UIKit collection views. This guide will walk you through installation, basic setup, and creating your first collection view.
+
+## Installation
+
+### Swift Package Manager
+
+Add DiffableUI to your project through Xcode:
+
+1. In Xcode, select **File** → **Add Package Dependencies**
+2. Enter the repository URL: `https://github.com/loloop/DiffableUI.git`
+3. Select the version you want to use
+4. Add DiffableUI to your target
+
+Or add it to your `Package.swift`:
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/loloop/DiffableUI.git", from: "0.0.1")
+]
+```
+
+## Creating Your First Collection View
+
+### Step 1: Create a View Controller
+
+Create a new view controller that inherits from `DiffableViewController`:
+
+```swift
+import UIKit
+import DiffableUI
+
+class MyViewController: DiffableViewController {
+ // Your view controller code
+}
+```
+
+### Step 2: Define Your Sections
+
+Override the `sections` property and use the `@CollectionViewBuilder` attribute:
+
+```swift
+class MyViewController: DiffableViewController {
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ Text("Hello, World!")
+ Text("Welcome to DiffableUI")
+ }
+ }
+}
+```
+
+### Step 3: Add Interactivity
+
+Add buttons and handle user interactions:
+
+```swift
+class MyViewController: DiffableViewController {
+ @State private var counter = 0
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ Text("Counter: \(counter)")
+
+ Button("Increment") {
+ counter += 1
+ reload() // Refresh the collection view
+ }
+ }
+ }
+}
+```
+
+## Common Patterns
+
+### Using Multiple Sections
+
+You can combine different section types in a single collection view:
+
+```swift
+@CollectionViewBuilder
+override var sections: [any CollectionSection] {
+ ListSection(header: "List Items") {
+ Text("Item 1")
+ Text("Item 2")
+ }
+
+ GridSection(columns: 2, header: "Grid Items") {
+ for i in 0..<6 {
+ Text("Grid \(i)")
+ }
+ }
+
+ CarouselSection {
+ Text("Carousel Item 1")
+ Text("Carousel Item 2")
+ Text("Carousel Item 3")
+ }
+}
+```
+
+### Handling Data
+
+Work with your data models by conforming them to appropriate protocols:
+
+```swift
+struct TodoItem: Identifiable {
+ let id = UUID()
+ let title: String
+ let isCompleted: Bool
+}
+
+class TodoViewController: DiffableViewController {
+ @State private var todos = [
+ TodoItem(title: "Learn DiffableUI", isCompleted: false),
+ TodoItem(title: "Build an app", isCompleted: false)
+ ]
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ ForEach(todos) { todo in
+ HStack {
+ Text(todo.title)
+ if todo.isCompleted {
+ Text("✓").foregroundColor(.systemGreen)
+ }
+ }
+ .onTap {
+ // Toggle completion
+ if let index = todos.firstIndex(where: { $0.id == todo.id }) {
+ todos[index] = TodoItem(
+ title: todo.title,
+ isCompleted: !todo.isCompleted
+ )
+ reload()
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+### Loading States
+
+Show loading indicators while fetching data:
+
+```swift
+class DataViewController: DiffableViewController {
+ @State private var isLoading = true
+ @State private var items: [String] = []
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ loadData()
+ }
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ if isLoading {
+ ActivityIndicator()
+ .centerAligned()
+ } else {
+ ForEach(items, id: \.self) { item in
+ Text(item)
+ }
+ }
+ }
+ }
+
+ private func loadData() {
+ // Simulate network request
+ Task {
+ try await Task.sleep(nanoseconds: 2_000_000_000)
+ await MainActor.run {
+ self.items = ["Item 1", "Item 2", "Item 3"]
+ self.isLoading = false
+ self.reload()
+ }
+ }
+ }
+}
+```
+
+## Next Steps
+
+- Explore the [example project](https://github.com/loloop/DiffableUI/tree/main/Examples/HackerNews) for a complete implementation
+- Learn about to build your own reusable components
+- Discover advanced layouts in
+- Implement user interactions with
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/HandlingUserInteraction.md b/Sources/DiffableUI/DiffableUI.docc/HandlingUserInteraction.md
new file mode 100644
index 0000000..f858aad
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/HandlingUserInteraction.md
@@ -0,0 +1,421 @@
+# Handling User Interaction
+
+Learn how to handle taps, swipes, and other user interactions in your collection views.
+
+## Overview
+
+DiffableUI provides multiple ways to handle user interactions, from simple taps to complex gestures. This guide covers the various interaction patterns and best practices for creating responsive, interactive collection views.
+
+## Basic Tap Handling
+
+### Using onTap Modifier
+
+The simplest way to handle taps is using the `.onTap` modifier:
+
+```swift
+@CollectionViewBuilder
+override var sections: [any CollectionSection] {
+ ListSection {
+ Text("Tap me")
+ .onTap {
+ print("Text was tapped!")
+ }
+
+ Button("Button") {
+ print("Button pressed!")
+ }
+ }
+}
+```
+
+### Implementing didSelect
+
+For custom items, implement the `didSelect()` method:
+
+```swift
+struct CustomItem: CollectionItem {
+ let id = UUID()
+ let title: String
+ var onSelect: (() -> Void)?
+
+ func didSelect() {
+ onSelect?()
+ // Perform selection action
+ }
+}
+```
+
+## Selection States
+
+### Single Selection
+
+Track and display selection state:
+
+```swift
+class SelectionViewController: DiffableViewController {
+ @State private var selectedID: String?
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ ForEach(items) { item in
+ SelectableItem(
+ content: Text(item.title),
+ isSelected: selectedID == item.id
+ )
+ .onTap {
+ selectedID = item.id
+ reload()
+ }
+ }
+ }
+ }
+}
+```
+
+### Multiple Selection
+
+Handle multiple selection with a Set:
+
+```swift
+class MultiSelectViewController: DiffableViewController {
+ @State private var selectedIDs = Set()
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ ForEach(items) { item in
+ HStack {
+ Image(systemName: selectedIDs.contains(item.id) ? "checkmark.circle.fill" : "circle")
+ .foregroundColor(.systemBlue)
+ Text(item.title)
+ }
+ .onTap {
+ if selectedIDs.contains(item.id) {
+ selectedIDs.remove(item.id)
+ } else {
+ selectedIDs.insert(item.id)
+ }
+ reload()
+ }
+ }
+ }
+ }
+}
+```
+
+## Swipe Actions
+
+### Basic Swipe to Delete
+
+Implement swipe actions on items:
+
+```swift
+@CollectionViewBuilder
+override var sections: [any CollectionSection] {
+ ListSection {
+ ForEach(todos) { todo in
+ Text(todo.title)
+ .swipeActions {
+ SwipeAction(
+ title: "Delete",
+ backgroundColor: .systemRed
+ ) {
+ deleteTodo(todo)
+ }
+ }
+ }
+ }
+}
+```
+
+### Multiple Swipe Actions
+
+Add multiple actions with different styles:
+
+```swift
+Text(email.subject)
+ .swipeActions {
+ SwipeAction(
+ title: "Archive",
+ backgroundColor: .systemBlue,
+ image: UIImage(systemName: "archivebox")
+ ) {
+ archiveEmail(email)
+ }
+
+ SwipeAction(
+ title: "Flag",
+ backgroundColor: .systemOrange,
+ image: UIImage(systemName: "flag")
+ ) {
+ flagEmail(email)
+ }
+
+ SwipeAction(
+ title: "Delete",
+ backgroundColor: .systemRed,
+ image: UIImage(systemName: "trash"),
+ style: .destructive
+ ) {
+ deleteEmail(email)
+ }
+ }
+```
+
+## Long Press Gestures
+
+### Adding Long Press
+
+Add long press recognition to items:
+
+```swift
+struct LongPressItem: CollectionItem {
+ // ... item implementation
+
+ func setBehaviors(cell: CellType) {
+ let longPress = UILongPressGestureRecognizer(
+ target: self,
+ action: #selector(handleLongPress(_:))
+ )
+ longPress.minimumPressDuration = 0.5
+ cell.addGestureRecognizer(longPress)
+ }
+
+ @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
+ if gesture.state == .began {
+ // Show context menu or perform action
+ showContextMenu()
+ }
+ }
+}
+```
+
+### Context Menus
+
+Provide context menus for additional actions:
+
+```swift
+Text("Long press for options")
+ .contextMenu {
+ UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
+ copyText()
+ }
+
+ UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in
+ shareText()
+ }
+
+ UIAction(
+ title: "Delete",
+ image: UIImage(systemName: "trash"),
+ attributes: .destructive
+ ) { _ in
+ deleteItem()
+ }
+ }
+```
+
+## Drag and Drop
+
+### Enabling Drag
+
+Make items draggable:
+
+```swift
+struct DraggableItem: CollectionItem {
+ // ... item implementation
+
+ func setBehaviors(cell: CellType) {
+ cell.addInteraction(
+ UIDragInteraction(delegate: self)
+ )
+ }
+}
+
+extension DraggableItem: UIDragInteractionDelegate {
+ func dragInteraction(
+ _ interaction: UIDragInteraction,
+ itemsForBeginning session: UIDragSession
+ ) -> [UIDragItem] {
+ let itemProvider = NSItemProvider(object: item.title as NSString)
+ return [UIDragItem(itemProvider: itemProvider)]
+ }
+}
+```
+
+### Handling Drop
+
+Accept dropped items:
+
+```swift
+class DragDropViewController: DiffableViewController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ collectionView.addInteraction(
+ UIDropInteraction(delegate: self)
+ )
+ }
+}
+
+extension DragDropViewController: UIDropInteractionDelegate {
+ func dropInteraction(
+ _ interaction: UIDropInteraction,
+ performDrop session: UIDropSession
+ ) {
+ // Handle the drop
+ session.loadObjects(ofClass: NSString.self) { items in
+ // Process dropped items
+ }
+ }
+}
+```
+
+## Interactive Animations
+
+### Tap Feedback
+
+Provide visual feedback for taps:
+
+```swift
+struct AnimatedTapItem: CollectionItem {
+ func configure(cell: CellType) {
+ // Configure cell
+ }
+
+ func setBehaviors(cell: CellType) {
+ let tapGesture = UITapGestureRecognizer(
+ target: self,
+ action: #selector(handleTap(_:))
+ )
+ cell.addGestureRecognizer(tapGesture)
+ }
+
+ @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
+ guard let cell = gesture.view else { return }
+
+ UIView.animate(
+ withDuration: 0.1,
+ animations: {
+ cell.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
+ },
+ completion: { _ in
+ UIView.animate(withDuration: 0.1) {
+ cell.transform = .identity
+ }
+ self.didSelect()
+ }
+ )
+ }
+}
+```
+
+### Interactive Transitions
+
+Create smooth interactive transitions:
+
+```swift
+class InteractiveViewController: DiffableViewController {
+ @State private var expandedItemID: String?
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ ForEach(items) { item in
+ if expandedItemID == item.id {
+ ExpandedItemView(item: item)
+ .onTap {
+ withAnimation {
+ expandedItemID = nil
+ reload()
+ }
+ }
+ } else {
+ CollapsedItemView(item: item)
+ .onTap {
+ withAnimation {
+ expandedItemID = item.id
+ reload()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+## Best Practices
+
+### 1. Provide Feedback
+
+Always provide visual or haptic feedback for interactions:
+
+```swift
+func didSelect() {
+ // Haptic feedback
+ let impactFeedback = UIImpactFeedbackGenerator(style: .light)
+ impactFeedback.impactOccurred()
+
+ // Visual feedback handled by the cell
+ onSelect?()
+}
+```
+
+### 2. Handle State Consistently
+
+Ensure your interaction state is consistent:
+
+```swift
+class StateViewController: DiffableViewController {
+ @State private var processingItemIDs = Set()
+
+ func processItem(_ item: Item) {
+ guard !processingItemIDs.contains(item.id) else { return }
+
+ processingItemIDs.insert(item.id)
+ reload()
+
+ Task {
+ await performProcessing(item)
+ processingItemIDs.remove(item.id)
+ reload()
+ }
+ }
+}
+```
+
+### 3. Respect Platform Conventions
+
+Follow iOS interaction patterns:
+
+```swift
+// Use standard iOS gestures
+Text("Swipe left for actions")
+ .swipeActions(edge: .trailing) { /* ... */ }
+
+// Use familiar icons
+Button(action: { /* ... */ }) {
+ Image(systemName: "ellipsis")
+}
+```
+
+### 4. Accessibility
+
+Ensure interactions are accessible:
+
+```swift
+func configure(cell: CellType) {
+ cell.isAccessibilityElement = true
+ cell.accessibilityLabel = item.title
+ cell.accessibilityHint = "Double tap to view details"
+ cell.accessibilityTraits = .button
+}
+```
+
+## Next Steps
+
+- Explore animation techniques in
+- Learn about performance optimization for interactive lists
+- See the HackerNews example for complex interaction patterns
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableUI.docc/ImplementingPagination.md b/Sources/DiffableUI/DiffableUI.docc/ImplementingPagination.md
new file mode 100644
index 0000000..cc0a588
--- /dev/null
+++ b/Sources/DiffableUI/DiffableUI.docc/ImplementingPagination.md
@@ -0,0 +1,569 @@
+# Implementing Pagination
+
+Learn how to implement efficient pagination patterns for large datasets.
+
+## Overview
+
+Pagination is essential for apps that work with large datasets. This guide covers various pagination strategies including infinite scrolling, page-based loading, cursor-based pagination, and handling edge cases.
+
+## Basic Pagination Setup
+
+### Data Structure
+
+First, define a structure to manage paginated data:
+
+```swift
+struct PaginatedData {
+ var items: [T] = []
+ var currentPage: Int = 0
+ var hasMorePages: Bool = true
+ var isLoading: Bool = false
+ var error: Error?
+
+ mutating func appendPage(_ newItems: [T], hasMore: Bool) {
+ items.append(contentsOf: newItems)
+ currentPage += 1
+ hasMorePages = hasMore
+ isLoading = false
+ error = nil
+ }
+
+ mutating func reset() {
+ items = []
+ currentPage = 0
+ hasMorePages = true
+ isLoading = false
+ error = nil
+ }
+}
+```
+
+### Pagination State
+
+Define states for your paginated view:
+
+```swift
+enum PaginationState {
+ case initial
+ case loading
+ case loaded(items: [T])
+ case loadingMore(items: [T])
+ case error(Error)
+ case allLoaded(items: [T])
+}
+```
+
+## Infinite Scrolling
+
+Implement automatic loading when scrolling near the end:
+
+```swift
+class InfiniteScrollViewController: DiffableViewController {
+ @State private var paginatedData = PaginatedData- ()
+ private let pageSize = 20
+ private let loadMoreThreshold = 5
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ loadInitialData()
+ }
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ ForEach(paginatedData.items) { item in
+ ItemView(item: item)
+ }
+
+ if paginatedData.isLoading {
+ LoadingIndicator()
+ } else if let error = paginatedData.error {
+ ErrorView(error: error) {
+ self.retryLoading()
+ }
+ }
+ }
+ }
+
+ override func collectionView(
+ _ collectionView: UICollectionView,
+ willDisplay cell: UICollectionViewCell,
+ forItemAt indexPath: IndexPath
+ ) {
+ super.collectionView(collectionView, willDisplay: cell, forItemAt: indexPath)
+
+ // Check if we should load more
+ let itemsCount = paginatedData.items.count
+ if indexPath.item >= itemsCount - loadMoreThreshold,
+ !paginatedData.isLoading,
+ paginatedData.hasMorePages {
+ loadMoreData()
+ }
+ }
+}
+```
+
+### Loading Logic
+
+```swift
+extension InfiniteScrollViewController {
+ private func loadInitialData() {
+ paginatedData.reset()
+ paginatedData.isLoading = true
+ reload()
+
+ Task {
+ do {
+ let items = try await fetchItems(page: 0, pageSize: pageSize)
+ paginatedData.appendPage(items, hasMore: items.count == pageSize)
+ reload()
+ } catch {
+ paginatedData.error = error
+ paginatedData.isLoading = false
+ reload()
+ }
+ }
+ }
+
+ private func loadMoreData() {
+ guard !paginatedData.isLoading else { return }
+
+ paginatedData.isLoading = true
+ reload()
+
+ Task {
+ do {
+ let items = try await fetchItems(
+ page: paginatedData.currentPage + 1,
+ pageSize: pageSize
+ )
+ paginatedData.appendPage(items, hasMore: items.count == pageSize)
+ reload()
+ } catch {
+ paginatedData.error = error
+ paginatedData.isLoading = false
+ reload()
+ }
+ }
+ }
+}
+```
+
+## Cursor-Based Pagination
+
+For APIs that use cursor-based pagination:
+
+```swift
+struct CursorPaginatedData {
+ var items: [T] = []
+ var nextCursor: String?
+ var isLoading: Bool = false
+ var error: Error?
+
+ var hasMore: Bool {
+ nextCursor != nil
+ }
+}
+
+class CursorPaginationViewController: DiffableViewController {
+ @State private var data = CursorPaginatedData()
+
+ private func loadMore() async {
+ guard !data.isLoading, data.hasMore else { return }
+
+ data.isLoading = true
+ reload()
+
+ do {
+ let response = try await API.fetchPosts(cursor: data.nextCursor)
+ data.items.append(contentsOf: response.posts)
+ data.nextCursor = response.nextCursor
+ data.isLoading = false
+ reload()
+ } catch {
+ data.error = error
+ data.isLoading = false
+ reload()
+ }
+ }
+}
+```
+
+## Load More Button
+
+Implement manual "Load More" button:
+
+```swift
+struct LoadMoreSection: CollectionSection {
+ let id = "load-more"
+ let hasMore: Bool
+ let isLoading: Bool
+ let onLoadMore: () -> Void
+
+ var items: [any CollectionItem] {
+ guard hasMore else { return [] }
+
+ if isLoading {
+ return [
+ ActivityIndicator()
+ .centerAligned()
+ .padding(.vertical, 20)
+ ]
+ } else {
+ return [
+ Button("Load More") {
+ onLoadMore()
+ }
+ .centerAligned()
+ .padding(.vertical, 20)
+ ]
+ }
+ }
+
+ func layout(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
+ let itemSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(60)
+ )
+ let item = NSCollectionLayoutItem(layoutSize: itemSize)
+
+ let groupSize = NSCollectionLayoutSize(
+ widthDimension: .fractionalWidth(1.0),
+ heightDimension: .absolute(60)
+ )
+ let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
+
+ return NSCollectionLayoutSection(group: group)
+ }
+}
+```
+
+## Bidirectional Pagination
+
+For timelines that load in both directions:
+
+```swift
+class TimelineViewController: DiffableViewController {
+ @State private var items: [TimelineItem] = []
+ @State private var oldestID: String?
+ @State private var newestID: String?
+ @State private var isLoadingNewer = false
+ @State private var isLoadingOlder = false
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ ListSection {
+ // Pull to refresh area
+ if isLoadingNewer {
+ ActivityIndicator()
+ .padding(.vertical, 20)
+ }
+
+ // Timeline items
+ ForEach(items) { item in
+ TimelineItemView(item: item)
+ }
+
+ // Load more area
+ if isLoadingOlder {
+ ActivityIndicator()
+ .padding(.vertical, 20)
+ }
+ }
+ }
+
+ private func loadNewer() async {
+ guard !isLoadingNewer else { return }
+
+ isLoadingNewer = true
+ reload()
+
+ do {
+ let newItems = try await API.fetchTimeline(
+ after: newestID,
+ limit: 20
+ )
+
+ if !newItems.isEmpty {
+ items.insert(contentsOf: newItems, at: 0)
+ newestID = newItems.first?.id
+ }
+
+ isLoadingNewer = false
+ reload()
+ } catch {
+ isLoadingNewer = false
+ reload()
+ }
+ }
+
+ private func loadOlder() async {
+ guard !isLoadingOlder else { return }
+
+ isLoadingOlder = true
+ reload()
+
+ do {
+ let newItems = try await API.fetchTimeline(
+ before: oldestID,
+ limit: 20
+ )
+
+ if !newItems.isEmpty {
+ items.append(contentsOf: newItems)
+ oldestID = newItems.last?.id
+ }
+
+ isLoadingOlder = false
+ reload()
+ } catch {
+ isLoadingOlder = false
+ reload()
+ }
+ }
+}
+```
+
+## Pagination with Search
+
+Combine search with pagination:
+
+```swift
+class SearchViewController: DiffableViewController {
+ @State private var searchQuery = ""
+ @State private var searchResults = PaginatedData()
+ private let searchDebouncer = Debouncer(delay: 0.3)
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupSearchBar()
+ }
+
+ private func setupSearchBar() {
+ let searchController = UISearchController(searchResultsController: nil)
+ searchController.searchResultsUpdater = self
+ searchController.obscuresBackgroundDuringPresentation = false
+ navigationItem.searchController = searchController
+ }
+
+ @CollectionViewBuilder
+ override var sections: [any CollectionSection] {
+ if searchQuery.isEmpty {
+ EmptyStateSection(
+ message: "Search for something...",
+ icon: "magnifyingglass"
+ )
+ } else if searchResults.items.isEmpty && !searchResults.isLoading {
+ EmptyStateSection(
+ message: "No results for '\(searchQuery)'",
+ icon: "magnifyingglass"
+ )
+ } else {
+ SearchResultsSection(
+ results: searchResults.items,
+ isLoading: searchResults.isLoading
+ )
+ }
+ }
+}
+
+extension SearchViewController: UISearchResultsUpdating {
+ func updateSearchResults(for searchController: UISearchController) {
+ let query = searchController.searchBar.text ?? ""
+
+ searchDebouncer.debounce { [weak self] in
+ self?.performSearch(query: query)
+ }
+ }
+
+ private func performSearch(query: String) {
+ guard !query.isEmpty else {
+ searchQuery = ""
+ searchResults.reset()
+ reload()
+ return
+ }
+
+ searchQuery = query
+ searchResults.reset()
+ searchResults.isLoading = true
+ reload()
+
+ Task {
+ do {
+ let results = try await API.search(
+ query: query,
+ page: 0,
+ pageSize: 20
+ )
+ searchResults.appendPage(results, hasMore: results.count == 20)
+ reload()
+ } catch {
+ searchResults.error = error
+ searchResults.isLoading = false
+ reload()
+ }
+ }
+ }
+}
+```
+
+## Caching and Offline Support
+
+Implement caching for better performance:
+
+```swift
+class CachedPaginationViewController: DiffableViewController {
+ @State private var paginatedData = PaginatedData()
+ private let cache = ArticleCache()
+
+ private func loadPage(_ page: Int) async {
+ // Try cache first
+ if let cachedItems = await cache.getPage(page) {
+ paginatedData.items = cachedItems
+ reload()
+ }
+
+ // Then fetch fresh data
+ do {
+ let items = try await API.fetchArticles(page: page)
+ await cache.savePage(page, items: items)
+ paginatedData.appendPage(items, hasMore: items.count == pageSize)
+ reload()
+ } catch {
+ // If network fails but we have cache, show cached data
+ if paginatedData.items.isEmpty,
+ let cachedItems = await cache.getPage(page) {
+ paginatedData.items = cachedItems
+ paginatedData.error = error // Show error banner
+ } else {
+ paginatedData.error = error
+ }
+ reload()
+ }
+ }
+}
+
+actor ArticleCache {
+ private var pages: [Int: [Article]] = [:]
+
+ func getPage(_ page: Int) -> [Article]? {
+ pages[page]
+ }
+
+ func savePage(_ page: Int, items: [Article]) {
+ pages[page] = items
+ }
+
+ func clear() {
+ pages.removeAll()
+ }
+}
+```
+
+## Performance Best Practices
+
+### 1. Prefetching
+
+```swift
+extension PaginationViewController: UICollectionViewDataSourcePrefetching {
+ func collectionView(
+ _ collectionView: UICollectionView,
+ prefetchItemsAt indexPaths: [IndexPath]
+ ) {
+ let maxIndex = indexPaths.map { $0.item }.max() ?? 0
+
+ if maxIndex >= items.count - prefetchThreshold {
+ Task {
+ await loadMoreIfNeeded()
+ }
+ }
+ }
+}
+```
+
+### 2. Debouncing
+
+```swift
+class Debouncer {
+ private let delay: TimeInterval
+ private var workItem: DispatchWorkItem?
+
+ init(delay: TimeInterval) {
+ self.delay = delay
+ }
+
+ func debounce(action: @escaping () -> Void) {
+ workItem?.cancel()
+
+ let workItem = DispatchWorkItem(block: action)
+ self.workItem = workItem
+
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + delay,
+ execute: workItem
+ )
+ }
+}
+```
+
+### 3. Memory Management
+
+```swift
+class PaginationViewController: DiffableViewController {
+ override func didReceiveMemoryWarning() {
+ super.didReceiveMemoryWarning()
+
+ // Keep only visible items + buffer
+ if let visibleIndexPaths = collectionView.indexPathsForVisibleItems {
+ let minIndex = max(0, (visibleIndexPaths.map { $0.item }.min() ?? 0) - 10)
+ let maxIndex = min(items.count, (visibleIndexPaths.map { $0.item }.max() ?? 0) + 10)
+
+ // Trim items outside visible range
+ items = Array(items[minIndex.. Void
+
+ var items: [any CollectionItem] {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 48))
+ .foregroundColor(.systemRed)
+
+ Text(error.localizedDescription)
+ .multilineTextAlignment(.center)
+
+ Button("Try Again") {
+ onRetry()
+ }
+ }
+ .padding()
+ }
+}
+```
+
+## Key Takeaways
+
+1. **Choose the Right Strategy**: Use infinite scroll for feeds, manual loading for control
+2. **Handle Edge Cases**: Empty states, errors, end of data
+3. **Optimize Performance**: Implement prefetching and caching
+4. **Provide Feedback**: Show loading states and error messages
+5. **Consider UX**: Maintain scroll position, handle refresh properly
+
+## Next Steps
+
+- Implement custom loading animations
+- Add skeleton screens while loading
+- Create reusable pagination components
+- Add analytics for pagination events
\ No newline at end of file
diff --git a/Sources/DiffableUI/DiffableViewController.swift b/Sources/DiffableUI/DiffableViewController.swift
index e67862a..e10a352 100644
--- a/Sources/DiffableUI/DiffableViewController.swift
+++ b/Sources/DiffableUI/DiffableViewController.swift
@@ -9,8 +9,57 @@
import Foundation
import UIKit
+/// A view controller that manages a collection view with diffable data source.
+///
+/// `DiffableViewController` provides a declarative way to build collection views
+/// using compositional layouts and diffable data sources. Subclass this controller
+/// and override the ``sections`` property to define your collection view content.
+///
+/// ## Overview
+///
+/// The view controller automatically handles:
+/// - Setting up the diffable data source
+/// - Configuring the compositional layout
+/// - Managing cell registration and dequeuing
+/// - Handling item selection and display callbacks
+/// - Performing efficient updates with animations
+///
+/// ## Example
+///
+/// ```swift
+/// class MyViewController: DiffableViewController {
+/// @CollectionViewBuilder
+/// override var sections: [any CollectionSection] {
+/// ListSection {
+/// Text("Hello, World!")
+/// Button("Tap me") {
+/// print("Button tapped")
+/// }
+/// }
+/// }
+/// }
+/// ```
+///
+/// ## Topics
+///
+/// ### Creating a View Controller
+///
+/// - ``init(configuration:)``
+///
+/// ### Defining Content
+///
+/// - ``sections``
+///
+/// ### Updating Content
+///
+/// - ``reload(animated:completion:)``
+/// - ``reload(animated:completion:)-4jmid``
open class DiffableViewController: UICollectionViewController {
+ /// Creates a new diffable view controller.
+ ///
+ /// - Parameter configuration: The compositional layout configuration to use.
+ /// The default configuration has no special behavior.
public init(configuration: UICollectionViewCompositionalLayoutConfiguration = .init()) {
layout = CollectionViewControllerLayout(configuration: configuration)
super.init(collectionViewLayout: layout.compositionalLayout)
@@ -38,6 +87,14 @@ open class DiffableViewController: UICollectionViewController {
private let layout: CollectionViewControllerLayout
+ /// Reloads the collection view with the current sections.
+ ///
+ /// This method recalculates the sections from the ``sections`` property and
+ /// applies the changes to the collection view using the diffable data source.
+ ///
+ /// - Parameters:
+ /// - animated: Whether to animate the changes. Defaults to `true`.
+ /// - completion: A closure to execute after the reload completes.
public func reload(animated: Bool = true, completion: (() -> Void)? = nil) {
let oldValue = self.computedSections
self.computedSections = sections
@@ -56,6 +113,14 @@ open class DiffableViewController: UICollectionViewController {
completion: completion)
}
+ /// Asynchronously reloads the collection view with the current sections.
+ ///
+ /// This method recalculates the sections from the ``sections`` property and
+ /// applies the changes to the collection view using the diffable data source.
+ ///
+ /// - Parameters:
+ /// - animated: Whether to animate the changes. Defaults to `true`.
+ /// - completion: A closure to execute after the reload completes.
@MainActor
public func reload(animated: Bool = true, completion: (() -> Void)? = nil) async {
let oldValue = self.computedSections
@@ -103,6 +168,26 @@ open class DiffableViewController: UICollectionViewController {
private(set) var computedSections = [any CollectionSection]()
+ /// The sections to display in the collection view.
+ ///
+ /// Override this property in your subclass and use the `@CollectionViewBuilder`
+ /// attribute to define your collection view content declaratively.
+ ///
+ /// ```swift
+ /// @CollectionViewBuilder
+ /// override var sections: [any CollectionSection] {
+ /// ListSection {
+ /// Text("Item 1")
+ /// Text("Item 2")
+ /// }
+ ///
+ /// GridSection(columns: 2) {
+ /// for i in 0..<10 {
+ /// Text("Grid item \(i)")
+ /// }
+ /// }
+ /// }
+ /// ```
@CollectionViewBuilder
open var sections: [any CollectionSection] {
fatalError("Override this with @CollectionViewBuilder!")
@@ -122,6 +207,10 @@ open class DiffableViewController: UICollectionViewController {
}
}
+ /// Handles item selection.
+ ///
+ /// This method is called when an item is selected and forwards the event
+ /// to the item's ``CollectionItem/didSelect()`` method.
public override func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath)
@@ -130,6 +219,10 @@ open class DiffableViewController: UICollectionViewController {
item.didSelect()
}
+ /// Handles cell display events.
+ ///
+ /// This method is called when a cell is about to be displayed and forwards
+ /// the event to the item's ``CollectionItem/willDisplay()`` method.
public override func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
diff --git a/Sources/DiffableUI/ResultBuilders/CollectionViewBuilder.swift b/Sources/DiffableUI/ResultBuilders/CollectionViewBuilder.swift
index 5e4d5ee..4f58d28 100644
--- a/Sources/DiffableUI/ResultBuilders/CollectionViewBuilder.swift
+++ b/Sources/DiffableUI/ResultBuilders/CollectionViewBuilder.swift
@@ -8,6 +8,29 @@
#if canImport(UIKit)
import Foundation
+/// A result builder that enables declarative syntax for composing collection view sections.
+///
+/// `CollectionViewBuilder` allows you to build collection views using a SwiftUI-like
+/// declarative syntax. It supports conditional statements, loops, and optional values.
+///
+/// ## Example
+///
+/// ```swift
+/// @CollectionViewBuilder
+/// var sections: [any CollectionSection] {
+/// ListSection {
+/// Text("Hello")
+/// }
+///
+/// if showGrid {
+/// GridSection(columns: 2) {
+/// ForEach(items) { item in
+/// ItemView(item: item)
+/// }
+/// }
+/// }
+/// }
+/// ```
@resultBuilder
public struct CollectionViewBuilder {
public static func buildBlock(_ components: any CollectionSection...) -> [any CollectionSection] {
diff --git a/Sources/DiffableUI/UI/Items/Label.swift b/Sources/DiffableUI/UI/Items/Label.swift
index 9fe23da..46c16f6 100644
--- a/Sources/DiffableUI/UI/Items/Label.swift
+++ b/Sources/DiffableUI/UI/Items/Label.swift
@@ -11,7 +11,23 @@ import UIKit
// MARK: - Declaration
+/// A collection item that displays a text label.
+///
+/// `Label` provides a simple way to display text in a collection view with
+/// customizable styling options.
+///
+/// ## Example
+///
+/// ```swift
+/// Label("Hello, World!")
+/// .textColor(.systemBlue)
+/// .fontStyle(.headline)
+/// .textAlignment(.center)
+/// ```
public struct Label: CollectionItem {
+ /// Creates a label with the specified text.
+ ///
+ /// - Parameter text: The text to display.
public init(_ text: String) {
item = text
}
@@ -42,18 +58,30 @@ extension Label {
cell.setFontStyle(configuration.fontStyle)
}
+ /// Sets the text alignment.
+ ///
+ /// - Parameter alignment: The text alignment to use.
+ /// - Returns: A label with the updated alignment.
public func textAlignment(_ alignment: NSTextAlignment) -> Self {
var copy = self
copy.configuration.alignment = alignment
return copy
}
+ /// Sets the text color.
+ ///
+ /// - Parameter color: The color to use for the text.
+ /// - Returns: A label with the updated text color.
public func textColor(_ color: UIColor) -> Self {
var copy = self
copy.configuration.textColor = color
return copy
}
+ /// Sets the font style using Dynamic Type.
+ ///
+ /// - Parameter style: The text style to use (e.g., .body, .headline).
+ /// - Returns: A label with the updated font style.
public func fontStyle(_ style: UIFont.TextStyle) -> Self {
var copy = self
copy.configuration.fontStyle = style
@@ -63,6 +91,7 @@ extension Label {
// MARK: - CollectionViewCell
+/// The cell used to display a `Label` item.
public final class LabelCell: CollectionViewCell {
override public func setUp() {
diff --git a/Sources/DiffableUI/UI/Sections/List.swift b/Sources/DiffableUI/UI/Sections/List.swift
index 74ff0c8..7464365 100644
--- a/Sources/DiffableUI/UI/Sections/List.swift
+++ b/Sources/DiffableUI/UI/Sections/List.swift
@@ -9,6 +9,22 @@
import Foundation
import UIKit
+/// A section that displays items in a vertical list layout.
+///
+/// `List` arranges items vertically, one per row, similar to a UITableView.
+/// It supports customizable item heights and spacing.
+///
+/// ## Example
+///
+/// ```swift
+/// List {
+/// Label("Item 1")
+/// Label("Item 2")
+/// Label("Item 3")
+/// }
+/// .itemHeight(.estimated(44))
+/// .insets(NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
+/// ```
public struct List: CollectionSection {
public let id: AnyHashable
public let items: [any CollectionItem]
@@ -37,6 +53,11 @@ public struct List: CollectionSection {
}
extension List {
+ /// Creates a list section with the specified items.
+ ///
+ /// - Parameters:
+ /// - id: A unique identifier for the section. Defaults to "list".
+ /// - items: A result builder closure that returns the items to display.
public init(
id: String = "list",
@CollectionItemBuilder items: () -> [any CollectionItem])
@@ -55,18 +76,31 @@ extension List {
var contentInsetsReference: UIContentInsetsReference = .automatic
}
+ /// Sets the height of items in the list.
+ ///
+ /// - Parameter dimension: The height dimension for items. Use `.estimated(_)` for dynamic heights
+ /// or `.absolute(_)` for fixed heights.
+ /// - Returns: A list with the updated item height.
public func itemHeight(_ dimension: NSCollectionLayoutDimension) -> Self {
var copy = self
copy.configuration.itemHeight = dimension
return copy
}
+ /// Sets the content insets reference for the list.
+ ///
+ /// - Parameter contentInsetsReference: The reference for interpreting content insets.
+ /// - Returns: A list with the updated content insets reference.
public func contentInsetsReference(_ contentInsetsReference: UIContentInsetsReference) -> Self {
var copy = self
copy.configuration.contentInsetsReference = contentInsetsReference
return copy
}
+ /// Sets the content insets for the list.
+ ///
+ /// - Parameter insets: The edge insets to apply to the section's content.
+ /// - Returns: A list with the updated insets.
public func insets(_ insets: NSDirectionalEdgeInsets) -> Self {
var copy = self
copy.configuration.insets = insets
diff --git a/deploy-gh-pages.sh b/deploy-gh-pages.sh
new file mode 100755
index 0000000..8b5ff25
--- /dev/null
+++ b/deploy-gh-pages.sh
@@ -0,0 +1,113 @@
+#!/bin/bash
+
+# Deploy documentation to GitHub Pages
+# This script builds the docs and deploys them to the gh-pages branch
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[0;33m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}📚 DiffableUI Documentation GitHub Pages Deployment${NC}"
+echo ""
+
+# Check if we're in a git repository
+if ! git rev-parse --git-dir > /dev/null 2>&1; then
+ echo -e "${RED}❌ Error: Not in a git repository${NC}"
+ exit 1
+fi
+
+# Check for uncommitted changes
+if ! git diff-index --quiet HEAD --; then
+ echo -e "${YELLOW}⚠️ Warning: You have uncommitted changes${NC}"
+ echo "It's recommended to commit or stash changes before deploying"
+ read -p "Continue anyway? (y/N) " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ exit 1
+ fi
+fi
+
+# Get current branch
+CURRENT_BRANCH=$(git branch --show-current)
+
+# Step 1: Generate the documentation website
+echo -e "${BLUE}🔨 Building documentation website...${NC}"
+if ./generate-docs-website.sh; then
+ echo -e "${GREEN}✅ Documentation website built successfully${NC}"
+else
+ echo -e "${RED}❌ Failed to build documentation website${NC}"
+ exit 1
+fi
+
+# Step 2: Check if gh-pages branch exists
+echo -e "${BLUE}🔍 Checking gh-pages branch...${NC}"
+if git show-ref --verify --quiet refs/heads/gh-pages; then
+ echo "gh-pages branch exists"
+else
+ echo "Creating gh-pages branch..."
+ git checkout --orphan gh-pages
+ git rm -rf .
+ git commit --allow-empty -m "Initial gh-pages commit"
+ git checkout "$CURRENT_BRANCH"
+fi
+
+# Step 3: Copy documentation to temporary directory
+echo -e "${BLUE}📋 Preparing documentation for deployment...${NC}"
+TEMP_DIR=$(mktemp -d)
+cp -R docs-website/* "$TEMP_DIR/"
+
+# Step 4: Switch to gh-pages branch
+echo -e "${BLUE}🔄 Switching to gh-pages branch...${NC}"
+git checkout gh-pages
+
+# Step 5: Clear existing content and copy new documentation
+echo -e "${BLUE}📁 Updating documentation...${NC}"
+# Remove everything except .git
+find . -maxdepth 1 ! -name '.git' ! -name '.' -exec rm -rf {} \;
+
+# Copy new documentation
+cp -R "$TEMP_DIR"/* .
+
+# Create .nojekyll file to disable Jekyll processing
+touch .nojekyll
+
+# Step 6: Commit and push changes
+echo -e "${BLUE}💾 Committing changes...${NC}"
+git add .
+git commit -m "Update documentation - $(date '+%Y-%m-%d %H:%M:%S')" || {
+ echo -e "${YELLOW}⚠️ No changes to commit${NC}"
+ git checkout "$CURRENT_BRANCH"
+ rm -rf "$TEMP_DIR"
+ exit 0
+}
+
+echo -e "${BLUE}🚀 Pushing to GitHub...${NC}"
+git push origin gh-pages
+
+# Step 7: Switch back to original branch
+echo -e "${BLUE}🔄 Switching back to $CURRENT_BRANCH...${NC}"
+git checkout "$CURRENT_BRANCH"
+
+# Cleanup
+rm -rf "$TEMP_DIR"
+
+# Step 8: Display success message
+echo ""
+echo -e "${GREEN}🎉 Documentation deployed successfully!${NC}"
+echo ""
+echo -e "${BLUE}📍 Your documentation will be available at:${NC}"
+echo " https://[your-github-username].github.io/DiffableUI/"
+echo ""
+echo -e "${BLUE}📝 Note:${NC} It may take a few minutes for GitHub Pages to update"
+echo ""
+echo -e "${BLUE}⚙️ To enable GitHub Pages:${NC}"
+echo " 1. Go to your repository settings on GitHub"
+echo " 2. Navigate to 'Pages' section"
+echo " 3. Set source to 'Deploy from a branch'"
+echo " 4. Select 'gh-pages' branch and '/ (root)' folder"
+echo " 5. Click Save"
\ No newline at end of file
diff --git a/docs.sh b/docs.sh
new file mode 100755
index 0000000..02110e6
--- /dev/null
+++ b/docs.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+
+# Build and open DiffableUI documentation
+
+echo "Building documentation..."
+xcodebuild docbuild -scheme DiffableUI -derivedDataPath .build
+
+if [ $? -eq 0 ]; then
+ echo "Documentation built successfully!"
+ echo "Opening documentation..."
+ open .build/Build/Products/Debug/DiffableUI.doccarchive
+else
+ echo "Documentation build failed!"
+ exit 1
+fi
\ No newline at end of file
diff --git a/generate-docs-website.sh b/generate-docs-website.sh
new file mode 100755
index 0000000..102d2bf
--- /dev/null
+++ b/generate-docs-website.sh
@@ -0,0 +1,129 @@
+#!/bin/bash
+
+# Generate static HTML website from DocC archive
+# This script builds the documentation and transforms it into a deployable website
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Configuration
+DERIVED_DATA_PATH=".build"
+ARCHIVE_PATH="$DERIVED_DATA_PATH/Build/Products/Debug/DiffableUI.doccarchive"
+OUTPUT_PATH="docs-website"
+HOSTING_BASE_PATH="/DiffableUI"
+
+echo -e "${BLUE}📚 DiffableUI Documentation Website Generator${NC}"
+echo ""
+
+# Function to check if a command exists
+command_exists() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+# Set docc path
+DOCC_PATH="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/bin/docc"
+
+# Check if docc is available
+if [ ! -f "$DOCC_PATH" ]; then
+ echo -e "${RED}❌ Error: 'docc' command not found at $DOCC_PATH${NC}"
+ echo "Please ensure Xcode 13.3+ is installed"
+ exit 1
+fi
+
+# Step 1: Build the documentation archive
+echo -e "${BLUE}📦 Building documentation archive...${NC}"
+if xcodebuild docbuild -scheme DiffableUI -derivedDataPath "$DERIVED_DATA_PATH" -quiet; then
+ echo -e "${GREEN}✅ Documentation archive built successfully${NC}"
+else
+ echo -e "${RED}❌ Failed to build documentation archive${NC}"
+ exit 1
+fi
+
+# Step 2: Check if archive exists
+if [ ! -d "$ARCHIVE_PATH" ]; then
+ echo -e "${RED}❌ Error: Documentation archive not found at $ARCHIVE_PATH${NC}"
+ exit 1
+fi
+
+# Step 3: Clean previous output
+if [ -d "$OUTPUT_PATH" ]; then
+ echo -e "${BLUE}🧹 Cleaning previous output directory...${NC}"
+ rm -rf "$OUTPUT_PATH"
+fi
+
+# Step 4: Process the archive into static HTML
+echo -e "${BLUE}🔄 Transforming archive to static HTML website...${NC}"
+echo " Output directory: $OUTPUT_PATH"
+echo " Hosting base path: $HOSTING_BASE_PATH"
+
+# Use docc to process the archive
+"$DOCC_PATH" process-archive \
+ transform-for-static-hosting "$ARCHIVE_PATH" \
+ --hosting-base-path "$HOSTING_BASE_PATH" \
+ --output-path "$OUTPUT_PATH"
+
+if [ $? -eq 0 ]; then
+ echo -e "${GREEN}✅ Static website generated successfully${NC}"
+else
+ echo -e "${RED}❌ Failed to generate static website${NC}"
+ exit 1
+fi
+
+# Step 5: Create an index.html redirect if needed
+if [ ! -f "$OUTPUT_PATH/index.html" ]; then
+ echo -e "${BLUE}📝 Creating index.html redirect...${NC}"
+ cat > "$OUTPUT_PATH/index.html" << EOF
+
+
+
+
+
+ DiffableUI Documentation
+
+
+
Redirecting to DiffableUI Documentation...
+
+
+EOF
+fi
+
+# Step 6: Create a simple local server script
+echo -e "${BLUE}📝 Creating local preview server script...${NC}"
+cat > "$OUTPUT_PATH/serve-locally.sh" << 'EOF'
+#!/bin/bash
+# Serve the documentation locally for preview
+
+PORT=${1:-8000}
+echo "🌐 Starting local documentation server on http://localhost:$PORT"
+echo "Press Ctrl+C to stop the server"
+python3 -m http.server $PORT
+EOF
+chmod +x "$OUTPUT_PATH/serve-locally.sh"
+
+# Step 7: Display summary
+echo ""
+echo -e "${GREEN}🎉 Documentation website generated successfully!${NC}"
+echo ""
+echo -e "${BLUE}📁 Output location:${NC} $OUTPUT_PATH"
+echo ""
+echo -e "${BLUE}🚀 Next steps:${NC}"
+echo " 1. To preview locally:"
+echo " cd $OUTPUT_PATH && ./serve-locally.sh"
+echo ""
+echo " 2. To deploy to GitHub Pages:"
+echo " - Copy contents of '$OUTPUT_PATH' to your gh-pages branch"
+echo " - Or use GitHub Actions to automate deployment"
+echo ""
+echo " 3. To deploy to other hosting services:"
+echo " - Upload contents of '$OUTPUT_PATH' to your web server"
+echo " - Ensure your server is configured to serve static files"
+echo ""
+echo -e "${BLUE}📝 Notes:${NC}"
+echo " - The site is configured for hosting at: $HOSTING_BASE_PATH"
+echo " - To change the hosting path, modify HOSTING_BASE_PATH in this script"
+echo " - The generated site is fully static and can be hosted anywhere"
\ No newline at end of file