Swift Combine Cheat Sheet

2023/07/04

Swift Combine Cheat Sheet

Publisher

Subject

Subscriber

Operator

Scheduler

Publisher

Declares that a type can transmit a sequence of values over time.

let publisher = ["cat", "dog", "monkey"].publisher

Apple has created a bunch of publishers that are defined as extensions of objects.

data task publisher:

URLSession.shared.dataTaskPublisher(for: url)

default notification center publisher:

NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotificaiton)

Future

A publisher that eventually produces a single value and then finishes or fails.

func generateAsyncRandomNumberFromFuture() -> Future <Int, Never> {
    return Future() { promise in
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            let number = Int.random(in: 1...10)
            promise(Result.success(number))
        }
    }
}

cancellable = generateAsyncRandomNumberFromFuture()
    .sink { number in print("Got random number \(number).") }
func createFuture() -> Future<Int, Never> {
    Future { promise in
        promise(.success(Int.random(in: (1...100))))
    }
}

func createDeferredFuture() -> Deferred<Future<Int, Never>> {
    return Deferred {
        Future { promise in
            promise(.success(Int.random(in: (1...100))))
        }
    }
}

Subscriber

A protocol that declares a type that can receive input from a publisher.

Built-in general-purpose subscribers

sink:

Attaches a subscriber with closure-based behavior.

["apple", "orange", "banana"].publisher.sink(receiveCompletion: { completion in
    print("completed: \(completion)")
}, receiveValue: { value in
    print("value received: \(value)")
})
[1, 2, 3].publisher.sink {
    print("received value \($0)")
}

assign:

Assigns each element from a publisher to a property on an object.

var player = Player()
["something nice"]
    .publisher
    .assign(to: \.introduction, on: player)

Publisher Lifecycle

[10, 14, 23]
    .publisher
    .map { "The area of the square with size \($0) is \($0 * $0)" }
    .sink { string in
        label.text = string
    }
["2", "birds", "with", "1", "stone"]
    .map { Int($0) }
    .replaceNil(with: 0)
    .sink { print($0) }
// 2
// 0
// 0
// 1
// 0

Subject

A subject is a publisher that you can use to ”inject” values into a stream, by calling its send(_:) method. This can be useful for adapting existing imperative code to the Combine model.

class Hero {
    var mana = CurrentValueSubject<Int, Never>(10)

    let manaCostPerPowerLevel = 1

    func castMagic(powerLevel: Int) {
        let manaNeeded = powerLevel * manaCostPerPowerLevel
        mana.value -= manaNeeded
    }
}

var cancellables = Set<AnyCancellable>()
let hero = Hero()
hero
    .mana
    .sink {
        print("Current mana: \($0)")
    }
    .store(in: &cancellables)

@Published property wrapper

Wrapping properties with the @Published property wrapper will turn them into publishers.

class Hero {
    @Published var mana = 10 // 1

    let manaCostPerPowerLevel = 1

    func castMagic(powerLevel: Int) {
        let manaNeeded = powerLevel * manaCostPerPowerLevel
        mana -= manaNeeded // 2
    }
}

var cancellables = Set<AnyCancellable>()
let hero = Hero()
hero
    .$mana // 3
    .sink {
        print("Current mana: \($0)")
    }
    .store(in: &cancellables)
  1. replaces var mana = CurrentValueSubject<Int, Never>(10) with @Published var mana = 10
  2. the value of the property can be accessed and modified directly
  3. to subscribe to a @Published property, we need to use $ prefix on the property name

This is a special convention for property wrapper that allows you to access the wrapper itself, aka “projected value”, rather than the value that is wrapped by the property. In the case where the wrapper’s projected value is a publisher, which can be subscribed to using $mana.

class Counter {
    @Published var publishedValue = 1
    var subjectValue = CurrentValueSubject<Int, Never>(1)
}

let c = Counter()
c.$publishedValue.sink { print("published", $0 == c.publishedValue) }
c.subjectValue.sink { print("subject", $0 == c.subjectValue.value) }
c.publishedValue = 2
c.subjectValue.value = 2

Output:

published true
subject true
published false
subject true

assign(to:on:)

Assign Output of a Publisher with assign(to:on:)

class Hero {
    @Published var mana = 10
    let manaCostPerPowerLevel = 1
}

struct HeroViewModel {
    var hero: Hero

    lazy var statusSubject: AnyPublisher<String?, Never> = {
        hero.$mana.map {
            "Hero now has \($0) mana left"
        }.eraseToAnyPublisher()
    }

    mutating func castMagic(powerLevel: Int) {
        let manaNeeded = powerLevel * manaCostPerPowerLevel
        hero.mana -= manaNeeded
    }
}

class HeroView {
    let statusLabel = UILabel()
    let castButton = UIButton()
    var viewModel: HeroViewModel
    var cancellables = Set<AnyCancellable>()

    init(viewModel: HeroViewModel) {
        self.viewModel = viewModel
    }

    func setupLabel() {
        viewModel.statusSubject
            .assign(to: \.text, on: statusLabel)
            .store(in: &cancellables)
    }

    func didTapCastButton() {
        viewModel.castMagic(powerLevel: 2)
    }
}

Operators

debounce

Built-in operator that limits publisher’s output by ignoring values that are rapidly followed by another value.

Use the debounce(for:scheduler:options:) operator to control the number of values and time between delivery of values from the upstream publisher. This operator is useful to process bursty or high-volume event streams where you need to reduce the number of values delivered to the downstream to a rate you specify.

removeDuplicates

Publishes only elements that don’t match the previous element.

Use removeDuplicates() to remove repeating elements from an upstream publisher. This operator has a two-element memory: the operator uses the current and previously published elements as the basis for its comparison.

filter

Republishes all elements that match a provided closure.

Combine’s filter(_:) operator performs an operation similar to that of filter in the Swift Standard Library: it uses a closure to test each element to determine whether to republish the element to the downstream subscriber.

throttle

Publishes either the most-recent or first element published by the upstream publisher in the specified time interval.

Use throttle(for:scheduler:latest:) to selectively republish elements from an upstream publisher during an interval you specify. Other elements received from the upstream in the throttling interval aren’t republished.

Transform Publisher Output to New Publisher

Sometimes you need to transform the output of a publisher into a different publisher:

var cancellables = Set<AnyCancellable>()

[1, 2, 3]
    .publisher
    .print()
    .flatMap { value in
        Array(repeating: value, count: 2).publisher
    }
    .sink { print("Got value: \($0)") }
    .store(in: &cancellables)

Output:

receive subscription: ([1, 2, 3])
request unlimited
receive value: (1)
Got value: 1
Got value: 1
receive value: (2)
Got value: 2
Got value: 2
receive value: (3)
Got value: 3
Got value: 3
receive finished
var cancellables = Set<AnyCancellable>()

[1, 2, 3]
    .publisher
    .print()
    .flatMap(maxPublishers: .max(1)) { value in
        Array(repeating: value, count: 2).publisher
    }
    .sink { print("Got value: \($0)") }
    .store(in: &cancellables)

Output:

receive subscription: ([1, 2, 3])
request max: (1)
receive value: (1)
Got value: 1
Got value: 1
request max: (1)
receive value: (2)
Got value: 2
Got value: 2
request max: (1)
receive value: (3)
Got value: 3
Got value: 3
request max: (1)
receive finished
let baseURL = URL(string: "https://www.test.com")!
var cancellables = Set<AnyCancellable>()
["/", "/content", "/pages", "/articles"]
    .publisher
    .setFailureType(to: URLError.self)
    .flatMap({ path -> URLSession.DataTaskPublisher in
        let url = baseURL.appendingPathComponent(path)
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .sink(receiveCompletion: { completion in
        print("Completed with: \(completion)") },
          receiveValue: { result in print(result)})
    .store(in: &cancellables)

Apply Operators that might Throw Error

Operators with a try prefix can throw error:

enum MyError: Error {
    case underEighteen
}

var cancellables = Set<AnyCancellable>()

[18, 17, 22, 35]
    .publisher
    .tryMap { age in
        guard age >= 18 else {
            throw MyError.underEighteen
        }
        return "Another player of age \(age) entered the game."
    }
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })

Output:

Another player of age 18 entered the game.
failure(__lldb_expr_13.MyError.underEighteen)

Define Custom Operators

extension Publisher where Output == String, Failure == Never {
    func toURLSessionDataTask(baseURL: URL) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLError> {
        if #available(iOS 14, *) {
            return flatMap {
                let url = baseURL.appendingPathComponent($0)
                return URLSession.shared.dataTaskPublisher(for: url)
            }
            .eraseToAnyPublisher()
        } else {
            return setFailureType(to: URLError.self)
                .flatMap {
                    let url = baseURL.appendingPathComponent($0)
                    return URLSession.shared.dataTaskPublisher(for: url)
                }
                .eraseToAnyPublisher()
        }
    }
}

["path1", "path2"]
    .publisher
    .toURLSessionDataTask(baseURL: URL(string: "https://myweb.com")!)
    .sink { print($0) }

Scheduler

Scheduler is the synchronization mechanism of the Combine framework, which defines the context for where and when the work is performed.

Combine does not work directly with threads. Instead, it allows Publishers to operate on specific Schedulers.

receive(on:) and subscribe(on:)

By default, Combine applies a default scheduler to the work, and the default scheduler will emit values downstream on the thread they were generated on.

extension Publishers {
  struct IsMainThread: Publisher {
    typealias Output = String
    typealias Failure = Never

    func receive<S>(subscriber: S) where S : Subscriber,
      Never == S.Failure, String == S.Input {

      debugPrint("IsMainThread: \(Thread.isMainThread)")
      subscriber.receive(subscription: Subscriptions.empty)

      DispatchQueue.main.async {
        _ = subscriber.receive("test")
      }
    }
  }
}

Without subscribe(on:):

Publishers.IsMainThread()
    .sink { _ in
      print("Sink: \(Thread.isMainThread)")
    }
    .store(in: &cancellables)

// output:
// "IsMainThread: true"
// Sink: true

With subscribe(on: DispatchQueue.global()):

Publishers.IsMainThread()
    .subscribe(on: DispatchQueue.global())
    .sink { _ in
      print("Sink: \(Thread.isMainThread)")
    }
    .store(in: &cancellables)
// output:
// "IsMainThread: false"
// Sink: true

Switching receive(on:):

Publishers.IsMainThread() // 1
  .receive(on: DispatchQueue.global()) // 2
  .map { value -> String in // 3
    debugPrint("Map: \(Thread.isMainThread)")
    return value
  }
  .receive(on: DispatchQueue.main) // 4
  .sink { _ in // 5
    print("Sink: \(Thread.isMainThread)")
  }
  .store(in: &cancellables)

// output:
// "IsMainThread: true"
// "Map: false"
// Sink: true
  1. the subscription is created on the current queue - the main queue
  2. switch the executuion to a new global queue
  3. map() executes
  4. switch to main queue
  5. sink executes

Mix and Match

In a multi-threaded environment it’s likely that you need a chain of operations including fetching data, processing data and updates UI, receive(on:) and subscribe(on:) can be mixed and matched:

[Resource Intensive Publisher]
  .subscribe(on: DispatchQueue.global())
  .receive(on: DispatchQueue.global())
  [Resource Intensive Processing of Output]
  .receive(on: DispatchQueue.main)
  [Update App UI on the Main Queue]

Use Cases

CollectionView via Combine

Use combine to drive collection view, when the underlying data changes, update the collection view so it displays newest data.

struct Player: Hashable, Decodable {
    let firstName: String
    let lastName: String
    let photoURL: URL
}

class DataProvider {

    let dataSubject = CurrentValueSubject<[Player], Never>([])

    func fetch() {
        let mockPlayers = [
            Player(firstName: "Lei",
                   lastName: "Li",
                   photoURL: URL(string: "http://player.photo/lei")!),
            Player(firstName: "Meimei",
                   lastName: "Han",
                   photoURL: URL(string: "http://player.photo/meimei")!)
        ]
        dataSubject.value = mockPlayers
    }

    func fetchImage(for player: Player) -> AnyPublisher<UIImage?, URLError> {
        URLSession.shared.dataTaskPublisher(for: player.photoURL)
            .map { UIImage(data: $0.data) }
            .eraseToAnyPublisher()
    }
}

final class PlayerCell: UICollectionViewCell {
    var cancellable: Cancellable?
    let imageView = UIImageView()
    // setup code omitted
    func configure(with player: Player) {
        // configure the cell
    }
}

final class PlayersViewController: UIViewController {

    private let dataProvider = DataProvider()
    private var cancellables = Set<AnyCancellable>()

    private lazy var dataSource: UICollectionViewDiffableDataSource<Int, Player> = {
        .init(collectionView: collectionView) { collectionView, indexPath, player in
            let cell = // dequeue PlayerCell
            cell.configure(with: player)
            cell.cancellable = self.dataProvider.fetchImage(for: player)
                .receive(on: DispatchQueue.main)
                .sink { cell.imageView.image = $0 }
                // .assign(to: \.image, on: cell.imageView)
            return cell
        }
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        dataProvider.fetch()
        dataProvider.dataSubject
            .sink { self.applySnapshots($0) }
            .store(in: &cancellables)
    }

    private func applySnapshots(_ players: [Player]) {
        var snapshot = NSDiffableDataSourceSnapshot<Int, Player>()
        snapshot.appendSections([0])
        snapshot.appendItems(players)
        dataSource.apply(snapshot)
    }
}

Assigning Publisher Output to @Published Property

Building an object that exposes a @Published property that represents some kind of state that can be updated due to external factors.

class DataProvider {
    @Published var fetchedPlayers = [Player]()

    private var currentPlayerID = 0

    func fetchNextPlayer() {
        let url = URL(string: "https://mygameserver.com/player/\(currentPlayerID)")!
        currentPlayerID += 1
        URLSession.shared.dataTaskPublisher(for: url)
            .tryMap { [weak self] value -> [Player] in
                let players = try JSONDecoder().decode([Player].self, from: value)
                return (self?.fetchedPlayers ?? []) + players
            }
            .replaceError(with: fetchedPlayers)
            .assign(to: &$fetchedPlayers)
    }
}

Simple Theming System

class ColorManager {
    enum PreferredUserInterfaceStyle: String {
        case lowEyeStrain, highContrast, system
    }

    lazy private(set) var themeSubject: CurrentValueSubject<PreferredUserInterfaceStyle, Never> = {
        var preferredStyle = PreferredUserInterfaceStyle.system
        return CurrentValueSubject<PreferredUserInterfaceStyle, Never>(currentInterfaceStyle)
    }()

    private var currentInterfaceStyle: PreferredUserInterfaceStyle {
        get {
            guard let styleValue = UserDefaults.standard.string(forKey: "preferredUserInterfaceStyle") else {
                return .system
            }
            return PreferredUserInterfaceStyle(rawValue: styleValue) ?? .system
        }
        set {
            UserDefaults.standard.set(newValue.rawValue, forKey: "preferredUserInterfaceStyle")
        }
    }
}

class SomeViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        themeManager.themeSubject.sink { style in
            updateUI(for: style)
        }
    }

    private func updateUI(for: style) {
        // …
    }
}

Updating UI based on User Input

class ViewController: UIViewController {
    let slider = UISlider()
    let label = UILabel()

    @Published var sliderValue: Float = 40

    var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        $sliderValue
            .map { "Slider is at \($0)" }
            .assign(to: \.text, on: label)
            .store(in: &cancellables)
        $sliderValue
            .assign(to: \.value, on: slider)
            .store(in: &cancellables)
        
        slider.addTarget(self, #selector(updateLabel), for: .valueChanged)
    }

    @objc
    func updateLabel() {
        sliderValue = slider.value
    }
}

Search Query

Perform search as user types:

final class ResultViewController: UIViewController {
    let textField = UITextField()
    let label = UILabel()

    @Published var searchQuery: String?

    var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        textField.addTarget(self, action: #selector(textChagned), for: .editingChanged)
        $searchQuery
            .debounce(for: 0.3, scheduler: DispatchQueue.global()) // 1
            .filter { ($0 && "").count > 2 } // 2
            .removeDuplicates() // 3
            .assign(to: \.text, on: label)
            .store(in: &cancellables)
    }

    @objc
    func textChanged() {
        searchQuery = textField.text
    }
}
  1. ignore values that are rapidly followed by another value; the timer is scheduled on global queue rather than the main queue, in order to not pause the timer when main queue is busy. For example, you might be scrolling through a table view while the timer is running in which case the timer will be paused until the scroll ends
  2. ignore values that are less than or equal to 2 characters
  3. ignore values that are duplicates of the previous values

Combine multiple publishers

Publishers.Zip

A publisher created by applying the zip function to two upstream publishers.

Use Publishers.Zip to combine the latest elements from two publishers and emit a tuple to the downstream. The returned publisher waits until both publishers have emitted an event, then delivers the oldest unconsumed event from each publisher together as a tuple to the subscriber.

If either upstream publisher finishes successfully or fails with an error, so too does the zipped publisher.

In addition to Publishers.Zip, there are Publishers.Zip3 and Publishers.Zip4.

Publishers.Zip(first, second)
    .sink { print("Zipped value: \($0)") }
    .store(in: &cancellables)
first.zip(with: second)
    .sink { print("Zipped value \($0)") }
    .store(in: &cancellables)

Publishers.Merge

A publisher created by applying the merge function to two upstream publishers.

Publishers.Merge emits a new value every time one of the publishers it merges emits a new value. Only a single value is emitted by interleaving all emiited values from the publishers that it merges.

Apple provides various versions of Publishers.Merge up to Publishers.Merge8 and Publishers.MergeMany. All versions of Pubilshers.Merge require that the publishers being merged have the same Output and Failure types.

Publishers.Merge(first, second)
    .sink { print("Merged value: \($0)") }
    .store(in: &cancellables)
first.merge(with: second)
    .sink { print("Merged value: \($0)") }
    .store(in: &cancellables)

Publishers.CombineLatest

Subscribes to an additional publisher and invokes a closure upon receiving output from either publisher.

Use combineLatest<P,T>(_:) to combine the current and one additional publisher and transform them using a closure you specify to publish a new value to the downstream. The combined publisher doesn’t produce elements until each of its upstream publishers publishes at least one element.

All upstream publishers need to finish for this publisher to finish. If an upstream publisher never publishes a value, this publisher never finishes. If any of the combined publishers terminates with a failure, this publisher also fails.

switchToLatest

Republishes elements sent by the most recently received publishers. Every time a publisher receives a new ‘Publisher’ as output, the previous subscriptions from the stream will automatically be cancelled.

Handle Network Call with Token

struct APIError: Decodable, Error {
    let statusCode: Int
}

func refreshToken() -> AnyPublisher<Bool, Never> {
    return Just(false).eraseToAnyPublisher()
}

func fetchURL<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
    URLSession.shared.dataTaskPublisher(for: url)
        .tryMap { result in
            let decoder = JSONDecoder()
            guard let urlResponse = result.response as? HTTPURLResponse,
                (200...299).contains(urlResponse.statusCode) else {
                    let apiError = try decoder.decode(APIError.self, from: result.data)
                    throw apiError
                }
            return try decoder.decode(T.self, from: result.data)
        }
        .tryCatch { error -> AnyPublisher<T, Error> in
            guard let apiError = error as? APIError, apiError.statusCode == 401 else {
                throw error
            }
            return refreshToken()
                .tryMap { success -> AnyPublisher<T, Error> in
                    guard success else { throw error }
                    return fetchURL(url)
                }
                switchToLatest().eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
}

UI based on Multiple Network Requests

Display a screen of sport news, including:

enum SectionType: String, Decodable {
    case highlights, favorites, recommended
}

struct NewsItem: Decodable, Hashable {
    let title: String
    let body: String
}

struct Content {
    let items: [NewsItem]
    let sectionType: SectionType

    static func highlights(items: [NewsItem]) -> Content {
        Content(items: items, sectionType: .highlights)
    }

    static func favorites(items: [NewsItem]) -> Content {
        Content(items: items, sectionType: .favorites)
    }

    static func recommended(items: [NewsItem]) -> Content {
        Content(items: items, sectionType: .recommended)
    }
}

var highlightsPublisher = URLSession.shared.dataTaskPublisher(for: highlightsContentURL)
    .map { $0. data }
    .decode(type: [NewsItem].self, decoder: JSONDecoder())
    .replaceError(with: [NewsItem]())
    .map { Content.highlights(items: $0) }
    .eraseToAnyPublisher()

var recommendedPublisher = URLSession.shared.dataTaskPublisher(for: recommendedContentURL)
    .map { $0.data }
    .decode(type: [NewsItem].self, decoder: JSONDecoder())
    .replaceError(with: [NewsItem]())
    .map { Content.recommended(items: $0) }
    .eraseToAnyPublisher()

class LocalFavorites {
    static func fetchAll() -> AnyPublisher<[NewsItem], Never> {
        // …
    }
}

var localFavoritesPublisher = LocalFavorites.fetchAll()
var remoteFavoritesPublisher = URLSession.shared.dataTaskPublisher(for: favoritesContentURL)
    .map { $0.data }
    .decode(type: [NewsItem].self, decoder: JSONDecoder())
    .replaceError(with: [Event]())
    .eraseToAnyPublisher()

var favouritesPublisher = Publishers.Zip(localFavoritesPublisher, remoteFavoritesPublisher)
    .map {
        let uniqueFavorites = Set($0.0 + $0.1)
        return Content.favorites(items: Array(uniqueFavorites))
    }
    .eraseToAnyPublisher()

var homeContentPublisher = Publishers.Merge3(highlightsPublisher,
                                             favoritesPublisher,
                                             recommendedPublisher)

homeContentPublisher
    .sink { content in
        switch content.sectionType {
        case .highlight: // render
        case .favorites: // render
        case .recommended: // render
        }
    }
    .store(in: &cancellables)

Ask for Push Permissions

extension UNUserNotificationCenter {
    func getNotificationSettings() -> Future<UNNotificationSettings, Never> {
        Future { promise in
            self.getNotificationSettings { settings in
                promise(.success(settings))
            }
        }
    }

    func requestAuthorization(options: UNAuthorizationOptions) -> Future<Bool, Error> {
        Future { promise in
            self.requestAuthorization(options: options) { result, error in
                if let error = error {
                    promise(.failure(error))
                } else {
                    promise(.success(result))
                }
            }
        }
    }
}
var cancellables = Set<AnyCancellable>()

UNUserNotificationCenter.current().getNotificationSettings()
    .flatMap { settings -> AnyPublisher<Bool, Never> in
        switch settings.authorizationStatus {
        case .notDetermined:
            return UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
                .replaceError(with: false)
                .eraseToAnyPublisher()
        case .denied:
            return Just(false).eraseToAnyPublisher()
        default:
            return Just(true).eraseToAnyPublisher()
        }
    }
    .receive(on: DispatchQueue.main)
    .sink { hasPermission in
        if hasPermission {
            // …
        } else {
            // …
        }
    }
    .store(in: &cancellables)

Observe UIControl Event with Publisher

Refer to: https://github.com/CombineCommunity/CombineCocoa for more.

Build a UIControl extension that can be subscribed to deal with specified event:

slider
    .publisher(for: .valueChanged)
    .sink { control in
        guard let slider = control as? UISlider else {
            return
        }
        // handle slider.value
    }

Extension:

extension UIControl {
    class EventSubscription<S: Subscriber>: Subscription where S.Input == UIControl, S.Failure == Never {
        let control: UIControl
        let event: UIControl.Event
        var subscriber: S?
        var currentDemand = Subscribers.Demand.none

        init(control: UIControl, event: UIControl.Event, subscriber: S) {
            self.control = control
            self.event = event
            self.subscriber = subscriber

            control.addTarget(self, action: #selector(onEvent), for: event)
        }

        func request(_ demand: Subscribers.Demand) {
            currentDemand += demand
        }

        func cancel() {
            subscriber = nil
            control.removeTarget(self, action: #selector(onEvent), for: event)
        }

        @objc
        func onEvent() {
            if currentDemand > 0 {
                currentDemand += subscriber?.receive(control) ?? .none
                currentDemand -= 1
            }
        }
    }

    struct EventPublisher: Publisher {
        typealias Output = UIControl
        typealias Failure = Never

        let control: UIControl
        let controlEvent: UIControl.Event

        func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, UIControl == S.Input {
            let subscription = EventSubscription(control: control, event: controlEvent, subscriber: subscriber)
            subscriber.receive(subscription: subscription)
        }
    }

    func publisher(for event: Event) -> EventPublisher {
        return EventPublisher(control: self, controlEvent: event)
    }
}

Layout Dimension for Different Purposes

In SwiftUI, there are often multiple ways of implementing the same UI.

struct HeartView: View {
    var body: some View {
        Circle()
            .fill(.yellow)
            .frame(width: 30, height: 30)
            .overlay(Image(systemName: "heart").foregroundColor(.red))
    }
}

struct ButtonView: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(Color.blue.gradient)
            .frame(width: 150, height: 50)
    }
}

// ZStack
struct IconDemo1: View {
    var body: some View {
        ZStack(alignment: .topTrailing) {
            ButtonView()
            HeartView()
                .alignmentGuide(.top, computeValue: { $0.height / 2 })
                .alignmentGuide(.trailing, computeValue: { $0.width / 2 })
        }
    }
}

// overlay
struct IconDemo2: View {
    var body: some View {
        ButtonView()
            .overlay(alignment: .topTrailing) {
                HeartView()
                    .alignmentGuide(.top, computeValue: { $0.height / 2 })
                    .alignmentGuide(.trailing, computeValue: { $0.width / 2 })
            }
    }
}

// background
struct IconDemo3: View {
    var body: some View {
            HeartView()
            .background(alignment:.center){
                ButtonView()
                    .alignmentGuide(HorizontalAlignment.center, computeValue: {$0[.trailing]})
                    .alignmentGuide(VerticalAlignment.center, computeValue: {$0[.top]})
            }
    }
}

Containers like ZStack, VStack, HStack, their required dimension are determined by the total dimension required for children views, whereas the required dimension of overlay and background are determined only by the main view they attach to.

If you want to have main view and supplementary view as a whole in term of layout alignment, ZStack is a good choice; If you need something that only the main view makes sense, overlay can be used.

Layout and Backing Layer

In SwiftUI, layout operated is done on Views, whereas operation on backing layer is done via Core Animation. As a result, anything done for CALayer cannot be reflected on SwiftUI.

SwiftUI has a number of these operations that happen after layout and before rendering, like offset, scaleEffect, rotationEffect, shadow, background and cornerRadius.

The following code lays out three rectangles evenly, the required dimension is the total size of them:

struct OffsetDemo1:View{
    var body: some View{
        HStack{
            Rectangle()
                .fill(.orange.gradient)
                .frame(maxWidth:.infinity)
            Rectangle()
                .fill(.green.gradient)
                .frame(maxWidth:.infinity)
            Rectangle()
                .fill(.cyan.gradient)
                .frame(maxWidth:.infinity)
        }
        .border(.red)
    }
}

After offsetting one rectangle, the required dimension calculated doesn’t change even though the second rectangle appears obviously offsetted:

struct OffsetDemo1:View{
    var body: some View{
        HStack{
            Rectangle()
                .fill(.orange.gradient)
                .frame(maxWidth:.infinity)
            Rectangle()
                .fill(.green.gradient)
                .frame(maxWidth:.infinity)
                .border(.blue)
                .offset(x: 30, y: 30)
                .border(.green)
            Rectangle()
                .fill(.cyan.gradient)
                .frame(maxWidth:.infinity)
        }
        .border(.red)
    }
}

This is because in SwiftUI, offset modifier internally uses Core Animation’s CGAffineTransform, .offset(x: 30, y: 30) translates to .transformEffect(.init(translationX: 30, y: 30)), it modifies CALayer which isn’t reflected into SwiftUI’s layout.

If you need the offset result be considered in layout calculation, try a different way:

Rectangle()
    .fill(.green.gradient)
    .frame(width: 100, height: 50)
    .border(.blue)
    .padding(EdgeInsets(top: 30, leading: 30, bottom: 0, trailing: 0))
    .border(.green)

Center Alignment - The Many Ways

There are multiple ways of centering a view, each may have caveats and reflect some implementation details of SwiftUI.

Spacer

var myView: some View {
    Text("Hello")
        .foregroundColor(.white)
        .font(.title)
        .lineLimit(1)
}

HStack {
    Spacer()
    myView
    Spacer()
}
.frame(width: 300, height: 60)
.background(.blue)

Two potential issues:

  1. long text will be truncated

Spacer has a default minimum length of 8, even it means the other views on the same axis don’t have enough space. You can explicitly set minLength:

Spacer(minLength: 0)

  1. top or bottom view in a vertical container may expand beyond safe area
VStack {
    SomeView()
        .background(.blue)
    Spacer() // make VStack occupy whole screen
}

Since SwiftUI 3.0, when adding ShapeStyle component using background, there’s a default value .all for ignoresSafeAreaEdges. So for the top view in above code we can ask it not to ignore safe area:

VStack {
    SomeView()
        .background(.blue, ignoresSafeAreaEdges: [])
    Spacer()
}

We can’t use Color instead of Spacer because when HStack and VStack tries to layout subviews, they provide subviews with four types of spaces: minimum, maximum, fixed and not specified, if a particular subview returns different required dimension for different space types, it means the subview’s dimension is variable, then HStack and VStack will first make sure fixed sized subviews get their required space, and whatever space leftover is divided by the number of variable size subviews.

Since Color and Text both have variable sizes, the result of using Color instead of Space to center align text will result in them having the same dimension.

One solution is to have layout priority assigned:

HStack {
    Color.clear
        .layoutPriority(0)
    hello
        .layoutPriority(1)
    Color.clear
        .layoutPriority(0)
}
.frame(width: 300, height: 60)
.background(Color.cyan)

Text("Hello world,hello world,hello world") // make sure text is long enough

The above code would show a truncated text horizontally center aligned, with equal space on both sides, this is due to HStack has a default spacing which is applied, explicitly make it 0 would make sure the text occupies maximum space.

HStack(spacing: 0)

Note, HStack and VStack won’t allocate spacing for Spacer because Spacer itself is space, in above code, if Spacer is used instead of Color, even without setting spacing to 0, text would still occupy the maximum space.

Using Color, Rectangle as spacer will utilise all available spaces in two dimension, there’s no need to specify a frame.

Alignment Guide

Center alignment can also be done using alignemnt guide.

ZStack { // default alignment .center
    Color.green
    text
}
.frame(width: 300, height: 60)

If the code chagnes to the following, it won’t work:

ZStack {
    Color.gray
        .frame(width: 300, height: 60)
    text
}

As a result, there can be two issues:

When spacing is nil for VStack and HStack, layout manager tries to get predefined spacing from each child view and use it for adjacent views; Since different view has different default spacing, it will appear that spacings are not evenly distributed in a larger container view.