From 9e42a0c39970110685e78bd028f63f45016e9379 Mon Sep 17 00:00:00 2001 From: Mauricio Cardozo Date: Sun, 20 Jul 2025 14:20:25 -0300 Subject: [PATCH 1/2] Add comprehensive DocC documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds complete DocC documentation for the DiffableUI framework including: ## Documentation Structure - Created DiffableUI.docc catalog with organized topics - Added comprehensive API documentation with examples - Created structured navigation and cross-references ## Core Documentation - CollectionItem protocol with detailed usage examples - CollectionSection protocol with layout guidance - DiffableViewController with complete lifecycle documentation - CollectionViewBuilder result builder documentation ## Getting Started Guide - Installation instructions for Swift Package Manager - Step-by-step tutorial for creating first collection view - Common patterns and data handling examples - Pull-to-refresh and error handling ## Advanced Guides - Creating Custom Items: Building reusable custom collection items - Creating Custom Sections: Custom layouts with compositional layout - Handling User Interaction: Taps, swipes, gestures, and selection - Building a News Feed: Complete example with pagination and loading states - Creating a Photo Grid: Responsive grid with selection and zooming - Implementing Pagination: Various pagination strategies and best practices ## Additional Files - CLAUDE.md: Guidance file for future Claude Code instances - docs.sh: Convenience script to build and open documentation ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 81 +++ Sources/DiffableUI/CollectionItem.swift | 90 +++ Sources/DiffableUI/CollectionSection.swift | 68 +++ .../DiffableUI.docc/BuildingANewsFeed.md | 498 +++++++++++++++ .../DiffableUI.docc/CreatingAPhotoGrid.md | 565 +++++++++++++++++ .../DiffableUI.docc/CreatingCustomItems.md | 310 ++++++++++ .../DiffableUI.docc/CreatingCustomSections.md | 352 +++++++++++ .../DiffableUI/DiffableUI.docc/DiffableUI.md | 62 ++ .../DiffableUI.docc/GettingStarted.md | 199 ++++++ .../HandlingUserInteraction.md | 421 +++++++++++++ .../DiffableUI.docc/ImplementingPagination.md | 569 ++++++++++++++++++ .../DiffableUI/DiffableViewController.swift | 93 +++ .../CollectionViewBuilder.swift | 23 + Sources/DiffableUI/UI/Items/Label.swift | 29 + Sources/DiffableUI/UI/Sections/List.swift | 34 ++ docs.sh | 15 + 16 files changed, 3409 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/BuildingANewsFeed.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/CreatingAPhotoGrid.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/CreatingCustomItems.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/CreatingCustomSections.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/DiffableUI.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/GettingStarted.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/HandlingUserInteraction.md create mode 100644 Sources/DiffableUI/DiffableUI.docc/ImplementingPagination.md create mode 100755 docs.sh 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/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 From 02bbd696262bdcf845e262645beb4e72dfe93c54 Mon Sep 17 00:00:00 2001 From: Mauricio Cardozo Date: Sun, 20 Jul 2025 17:19:16 -0300 Subject: [PATCH 2/2] Add documentation website generation scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate-docs-website.sh: Builds DocC archive and transforms it to static HTML - deploy-gh-pages.sh: Deploys documentation to GitHub Pages branch - GitHub Actions workflow for automatic documentation deployment - Local preview server script included in generated website The scripts use `docc process-archive transform-for-static-hosting` to create a fully static website that can be hosted on GitHub Pages or any static host. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/workflows/deploy-docs.yml | 82 +++++++++++++++++++ deploy-gh-pages.sh | 113 ++++++++++++++++++++++++++ generate-docs-website.sh | 129 ++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 .github/workflows/deploy-docs.yml create mode 100755 deploy-gh-pages.sh create mode 100755 generate-docs-website.sh 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/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/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