home / skills / pluginagentmarketplace / custom-plugin-swift / swift-combine

swift-combine skill

/skills/swift-combine

npx playbooks add skill pluginagentmarketplace/custom-plugin-swift --skill swift-combine

Review the files below or copy the command above to add this skill to your agents.

Files (6)
SKILL.md
9.2 KB
---
name: swift-combine
description: Master Combine framework for reactive programming - publishers, subscribers, operators, schedulers
version: "2.0.0"
sasmp_version: "1.3.0"
bonded_agent: 03-swift-swiftui
bond_type: SECONDARY_BOND
---

# Swift Combine Skill

Reactive programming with Apple's Combine framework for handling asynchronous events and data streams.

## Prerequisites

- iOS 13+ / macOS 10.15+
- Understanding of closures and generics
- Familiarity with async programming concepts

## Parameters

```yaml
parameters:
  use_async_await:
    type: boolean
    default: true
    description: Prefer async/await where possible (iOS 15+)
  scheduler:
    type: string
    enum: [main, background, immediate]
    default: main
  error_handling:
    type: string
    enum: [catch, retry, replace]
    default: catch
```

## Topics Covered

### Core Concepts
| Concept | Description |
|---------|-------------|
| Publisher | Emits values over time |
| Subscriber | Receives values from publisher |
| Operator | Transforms/filters values |
| Subject | Publisher + manual value injection |
| Cancellable | Subscription lifecycle |

### Common Publishers
| Publisher | Purpose |
|-----------|---------|
| `Just` | Single value, then complete |
| `Future` | Single async result |
| `PassthroughSubject` | Manual value broadcast |
| `CurrentValueSubject` | Manual + current value |
| `@Published` | Property wrapper publisher |

### Key Operators
| Category | Operators |
|----------|-----------|
| Transform | map, flatMap, scan |
| Filter | filter, removeDuplicates, compactMap |
| Combine | merge, combineLatest, zip |
| Timing | debounce, throttle, delay |
| Error | catch, retry, mapError |

## Code Examples

### Basic Publisher Chain
```swift
import Combine

final class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published private(set) var results: [SearchResult] = []
    @Published private(set) var isLoading = false
    @Published private(set) var error: Error?

    private var cancellables = Set<AnyCancellable>()
    private let searchService: SearchService

    init(searchService: SearchService) {
        self.searchService = searchService
        setupSearch()
    }

    private func setupSearch() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main) // Wait for typing pause
            .removeDuplicates() // Don't search same query twice
            .filter { $0.count >= 2 } // Minimum characters
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
                self?.error = nil
            })
            .flatMap { [searchService] query -> AnyPublisher<[SearchResult], Never> in
                searchService.search(query: query)
                    .catch { error -> Just<[SearchResult]> in
                        // Handle error, return empty
                        return Just([])
                    }
                    .eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] results in
                self?.isLoading = false
                self?.results = results
            }
            .store(in: &cancellables)
    }
}
```

### Subjects for Event Broadcasting
```swift
final class EventBus {
    static let shared = EventBus()

    // PassthroughSubject - no current value
    let userActions = PassthroughSubject<UserAction, Never>()

    // CurrentValueSubject - maintains current value
    let authState = CurrentValueSubject<AuthState, Never>(.loggedOut)

    private init() {}

    func send(_ action: UserAction) {
        userActions.send(action)
    }

    func login(user: User) {
        authState.send(.loggedIn(user))
    }

    func logout() {
        authState.send(.loggedOut)
    }
}

enum UserAction {
    case tappedButton(String)
    case viewedScreen(String)
    case completedPurchase(orderId: String)
}

enum AuthState {
    case loggedOut
    case loggedIn(User)
}

// Subscription
class AnalyticsService {
    private var cancellables = Set<AnyCancellable>()

    init() {
        EventBus.shared.userActions
            .sink { [weak self] action in
                self?.track(action)
            }
            .store(in: &cancellables)
    }

    private func track(_ action: UserAction) {
        // Send to analytics
    }
}
```

### Combining Multiple Publishers
```swift
final class CheckoutViewModel: ObservableObject {
    @Published var cartItems: [CartItem] = []
    @Published var shippingAddress: Address?
    @Published var paymentMethod: PaymentMethod?
    @Published var promoCode: String = ""

    @Published private(set) var canCheckout = false
    @Published private(set) var total: Decimal = 0

    private var cancellables = Set<AnyCancellable>()

    init() {
        setupValidation()
        setupTotalCalculation()
    }

    private func setupValidation() {
        Publishers.CombineLatest3($cartItems, $shippingAddress, $paymentMethod)
            .map { items, address, payment in
                !items.isEmpty && address != nil && payment != nil
            }
            .assign(to: &$canCheckout)
    }

    private func setupTotalCalculation() {
        Publishers.CombineLatest($cartItems, $promoCode)
            .map { items, promo -> Decimal in
                let subtotal = items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
                let discount = self.calculateDiscount(promo: promo, subtotal: subtotal)
                return subtotal - discount
            }
            .assign(to: &$total)
    }

    private func calculateDiscount(promo: String, subtotal: Decimal) -> Decimal {
        // Promo code logic
        return 0
    }
}
```

### Error Handling & Retry
```swift
extension Publisher {
    func retryWithBackoff(
        maxRetries: Int = 3,
        initialDelay: TimeInterval = 1,
        maxDelay: TimeInterval = 30
    ) -> AnyPublisher<Output, Failure> {
        self.catch { error -> AnyPublisher<Output, Failure> in
            guard maxRetries > 0 else {
                return Fail(error: error).eraseToAnyPublisher()
            }

            let delay = min(initialDelay * pow(2, Double(3 - maxRetries)), maxDelay)

            return Just(())
                .delay(for: .seconds(delay), scheduler: DispatchQueue.global())
                .flatMap { _ in
                    self.retryWithBackoff(
                        maxRetries: maxRetries - 1,
                        initialDelay: initialDelay,
                        maxDelay: maxDelay
                    )
                }
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

// Usage
apiClient.fetchData()
    .retryWithBackoff(maxRetries: 3)
    .catch { error -> Just<Data> in
        Logger.network.error("Final failure: \(error)")
        return Just(Data())
    }
    .sink { data in
        // Process data
    }
    .store(in: &cancellables)
```

### Bridging to async/await
```swift
extension Publisher {
    func firstValue() async throws -> Output {
        try await withCheckedThrowingContinuation { continuation in
            var cancellable: AnyCancellable?

            cancellable = self.first()
                .sink(
                    receiveCompletion: { completion in
                        switch completion {
                        case .finished:
                            break
                        case .failure(let error):
                            continuation.resume(throwing: error)
                        }
                        cancellable?.cancel()
                    },
                    receiveValue: { value in
                        continuation.resume(returning: value)
                    }
                )
        }
    }
}

// Usage
let result = try await somePublisher.firstValue()
```

## Troubleshooting

### Common Issues

| Issue | Cause | Solution |
|-------|-------|----------|
| Sink never called | No strong reference | Store in cancellables Set |
| UI not updating | Wrong scheduler | Use .receive(on: DispatchQueue.main) |
| Memory leak | Strong reference in closure | Use [weak self] |
| Duplicate events | Missing removeDuplicates | Add .removeDuplicates() |
| Publisher completes early | Using first() or prefix() | Check operator semantics |

### Debug Tips
```swift
// Print debug info for each event
publisher
    .print("Debug")
    .sink { ... }

// Handle events at each stage
publisher
    .handleEvents(
        receiveSubscription: { _ in print("Subscribed") },
        receiveOutput: { print("Output: \($0)") },
        receiveCompletion: { print("Completed: \($0)") },
        receiveCancel: { print("Cancelled") }
    )
    .sink { ... }

// Breakpoint on specific conditions
publisher
    .breakpoint(receiveOutput: { $0 > 100 })
    .sink { ... }
```

## Validation Rules

```yaml
validation:
  - rule: store_cancellables
    severity: error
    check: All subscriptions must be stored in cancellables
  - rule: weak_self_in_closures
    severity: warning
    check: Use [weak self] in sink closures
  - rule: receive_on_main
    severity: warning
    check: UI updates must receive on main queue
```

## Usage

```
Skill("swift-combine")
```

## Related Skills

- `swift-swiftui` - @Published integration
- `swift-concurrency` - async/await alternative
- `swift-networking` - Network publishers