swift-signal

main

A Swift package that provides reactivity computation inspired by Solid.
unixzii/swift-signal

Swift Signal

Swift Signal is a Swift package that provides reactivity computation inspired by Solid.

If you're familiar with Solid or signals, this package will be easy to get started with. The API design is mostly the same as Solid, except for language style differences.

The package is divided into two libraries: core library, and SwiftUI integration library.

Warning

This is an experimental package. Please use with caution if you are developing a production app.

Getting Started

In your Package.swift manifest file, add the following dependency to your dependencies argument:

.package(url: "https://github.com/unixzii/swift-signal.git", branch: "main"),

Add the dependency to any targets you've declared in your manifest:

.target(
    name: "MyTarget",
    dependencies: [
        .product(name: "SwiftSignal", package: "swift-signal"),
        // Also add this if you need SwiftUI integration.
        .product(name: "SwiftUISignal", package: "swift-signal"),
    ]
),

Basic Usage

The Signal Type

Signals are the most basic reactive primitive. They track a single value that changes over time. To create a signal, simply instantiate a Signal object:

let count = Signal(initialValue: 1)
let ready = Signal(initialValue: true)

You can call the signal as a function to read its current value:

print(count())

To set or update the value of a signal, call set or update method respectively:

count.set(42)
count.update { $0 + 1 }

Effects

Effect is a general way to make arbitrary code ("side effects") run whenever its dependencies change. Effect creates a computation that runs the given closure in a tracking scope, thus automatically tracking its dependencies, and automatically reruns the closure whenever the dependencies update. To create an effect, call createEffect function:

let count = Signal(initialValue: 0)

let disposeEffect = createEffect {
    print(count())
    return nil
}

count.set(1)

Running the code will receive the below output:

0
1

To dispose (destroy) an effect, just invoke the returned closure of createEffect.

Note

You must keep a reference of the dispose closure, or the effect may not work as expected.

You can return a cleanup closure inside createEffect, the closure will be invoked on disposal or every time the effect's dependencies change:

createEffect {
    return {
        print("cleanup code here")
    }
}

Computed (Derived) Values

createComputed creates a computed (derived) value by executing the given closure. It returns a getter closure to retrieve the computed value. The compute closure will only get executed when its dependencies change.

let signalA = Signal(initialValue: 0)
let signalB = Signal(initialValue: 0)
let sum = createComputed {
    return signalA() + signalB()
}

This primitive is like createMemo in Solid. You can wrap time-consuming computations with createComputed to optimize the performance. It's usually a good practice to memorize computations that will execute more than once.

Note

Unlike createEffect, computed closure will only get executed when it's read explicitly or observed by effects. Use createEffect if you want to react to the changes of signals or computed values.

Using in SwiftUI

By importing SwiftUISignal module, you can integrate signals with SwiftUI. We will demonstrate it via a simple app.

First, create some reactive values:

let counterA = Signal(initialValue: 1)
let counterB = Signal(initialValue: 1)
let selectedCounter = Signal(initialValue: 1)
let message = createComputed {
    if selectedCounter() == 1 {
        return "Counter A: \(counterA())"
    } else {
        return "Counter B: \(counterB())"
    }
}
let sum = createComputed {
    return counterA() + counterB()
}

Then you can read it from SwiftUI views using ObservedComputed:

struct MessageView: View {
    @ObservedObject private var observedMessage = ObservedComputed {
        return message()
    }

    var body: some View {
        Text(observedMessage())
    }
}

struct SumView: View {
    @ObservedObject private var observedSum = ObservedComputed {
        return "Sum: \(sum())"
    }

    var body: some View {
        Text(observedSum())
    }
}

Every time the dependencies change, the dependent view will be updated automatically.

Finally, composite them in the root view:

struct ContentView: View {
    var body: some View {
        VStack {
            MessageView()
            SumView()

            Toggle("Selected Counter", isOn: .init(get: {
                return selectedCounter() == 2
            }, set: { newValue in
                selectedCounter.write(newValue ? 2 : 1)
            }))
            .toggleStyle(SwitchToggleStyle())

            HStack {
                Button("+A") {
                    counterA.update { $0 + 1 }
                }
                Button("+B") {
                    counterB.update { $0 + 1 }
                }
            }
        }
        .padding()
    }
}

You can play with the app, and explore the fine-grained reactivity by observing the updates of each view.

@StateObject vs @ObservedObject

Both ObservedSignal and ObservedComputed comform to ObservableObject protocol, which must be used with @StateObject or @ObservedObject property wrapper. Make decisions in the way you handle other ordinary ObservableObject models. Normally, @StateObject is used to provide a single source of truth, and @ObservedObject is used to observe the pass-in properties.

Passing Data Between SwiftUI and AppKit / UIKit

Signal is a better way to pass data between SwiftUI and other UI frameworks. For example, you can create a signal in an AppKit view controller, and pass it to the hosted SwiftUI view. Then you can conveniently update the SwiftUI view outside SwiftUI environment, and create two-way bindings with effects.

class ViewController: NSViewController {
    let count = Signal(initialValue: 0)
    var countEffect: DisposeAction?

    override func loadView() {
        view = NSHostingView(rootView: MyView(count: count))
        countEffect = createEffect { [unowned self] in
            let currentCount = count()
            
            // Handle count changes...
            
            return nil
        }
    }
}

struct MyView: View {
    @ObservedObject private var count: ObservedComputed<Int>
    private let countSignal: Signal<Int>
    
    init(count: Signal<Int>) {
        self.count = .init {
            return count()
        }
        self.countSignal = count
    }
    
    var body: some View {
        VStack {
            Text("\(count())")
            Button("Increase") {
                countSignal.update { $0 + 1 }
            }
        }
    }
}

Contributing

Pull requests are welcomed. At this stage, we are still evaluate the possibility of signals in Swift. Please open an issue before making significant changes.

License

Licensed under MIT License, see LICENSE for more information.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

  • None
Last updated: Thu Dec 12 2024 20:24:21 GMT-1000 (Hawaii-Aleutian Standard Time)