Riveting is a Swift implementation of the RIV (Reducer, Interactor, View) architecture for iOS applications, leveraging Swift's AsyncStream for reactive state management.
RIV is a unidirectional data flow architecture that separates concerns into three main components.
- Reducer: Transforms domain models into view states
- Interactor: Processes actions and manages domain state
- View: Renders the UI based on view state and sends actions
This separation creates a clean, testable architecture with a predictable data flow:
- The View sends actions to the Interactor
- The Interactor processes actions and updates the domain state
- The Reducer transforms the domain state into view state
- The View renders based on the new view state
- Unidirectional Data Flow: Predictable state management with a clear flow of data
- Async/Await Support: Built on Swift's modern concurrency model
- Type Safety: Leverages Swift's strong type system for compile-time safety
- Testability: Components designed for easy unit testing
- SwiftUI Integration: Seamless integration with SwiftUI's declarative UI paradigm
- UIKit Support: Also works with UIKit through the Navigator protocol
Add Riveting to your Package.swift file:
dependencies: [
.package(url: "https://github.com/username/Riveting.git", exactVersion: "<latest_version>")
]Then add the dependency to your target:
.target(
name: "YourApp",
dependencies: ["Riveting"]
)For testing support, add the RivetingTestSupport package only to your test target:
.testTarget(
name: "YourAppTests",
dependencies: ["Riveting", "RivetingTestSupport"]
)A feature in RIV consists of three main components:
- Domain Model: Represents the state of your feature
- Interactor: Processes actions and updates the domain
- Reducer: Transforms domain state to view state
- Feature: Connects the interactor and reducer to the view
// 1. Define your domain model
struct CounterDomain {
var count: Int = 0
}
// 2. Define actions
enum CounterAction {
case increment
case decrement
case reset
}
// 3. Create an interactor
class CounterInteractor: BaseInteractor<CounterAction, CounterDomain> {
override func interact(with action: CounterAction) {
switch action {
case .increment:
updateDomain { domain in
domain.count += 1
}
case .decrement:
updateDomain { domain in
domain.count -= 1
}
case .reset:
updateDomain { domain in
domain.count = 0
}
}
}
}
// 4. Create a reducer
struct CounterReducer: Reducing {
func reduce(from domain: CounterDomain) -> CounterViewState {
CounterViewState(
count: domain.count,
isNegative: domain.count < 0
)
}
}
// 5. Define your view state
struct CounterViewState {
let count: Int
let isNegative: Bool
}
// 6. Create a feature
class CounterFeature: BaseFeature<CounterInteractor, CounterReducer> {
// BaseFeature provides all the necessary functionality
}struct CounterView: View {
@StateObject private var feature = CounterFeature(
interactor: CounterInteractor(initialDomain: CounterDomain()),
reducer: CounterReducer()
)
var body: some View {
VStack {
Text("Count: \(feature.viewState.count)")
.foregroundColor(feature.viewState.isNegative ? .red : .primary)
HStack {
Button("Decrement") {
feature.send(.decrement)
}
Button("Reset") {
feature.send(.reset)
}
Button("Increment") {
feature.send(.increment)
}
}
}
.padding()
}
}Riveting is designed to be flexible with your navigation choices:
You can use SwiftUI's native navigation components (NavigationStack, NavigationLink, etc.) directly with Riveting features:
struct MainView: View {
@StateObject private var feature = CounterFeature(
interactor: CounterInteractor(initialDomain: CounterDomain()),
reducer: CounterReducer()
)
var body: some View {
NavigationStack {
VStack {
Text("Count: \(feature.viewState.count)")
Button("Increment") {
feature.send(.increment)
}
NavigationLink("Go to Details") {
DetailView(count: feature.viewState.count)
}
}
}
}
}If you prefer or need to use a UIKit-based navigation stack, Riveting provides NavigationRouter and NavigableView components:
// Define navigation events
enum ProfileNavigationEvent {
case showSettings
case showDetails(userId: String)
case dismiss
}
// Create a navigation router
class ProfileNavigationRouter: NavigationRouter {
weak var navigator: Navigator?
func navigate(_ event: ProfileNavigationEvent) {
switch event {
case .showSettings:
navigator?.push(SettingsView(), animated: true)
case .showDetails(let userId):
navigator?.push(UserDetailsView(userId: userId), animated: true)
case .dismiss:
navigator?.dismiss(animated: true)
}
}
}
// Use in a view
struct ProfileView: NavigableView {
let navigationRouter: ProfileNavigationRouter
var body: some View {
VStack {
Button("Settings") {
navigate(.showSettings)
}
Button("User Details") {
navigate(.showDetails(userId: "123"))
}
Button("Dismiss") {
navigate(.dismiss)
}
}
}
}The repository includes an example project demonstrating the RIV architecture in action:
- Searching: A search feature that demonstrates async data loading, error handling, and navigation
To run the examples, clone the repository and open the example project in Xcode.
Riveting includes test support utilities to make testing your features easier:
import Testing
import Riveting
import RivetingTestSupport
struct CounterInteractorTests {
private let sut: CounterInteractor
init() {
self.sut = CounterInteractor(initialDomain: CounterDomain(count: 0))
}
@Test
func increment() {
sut.interact(with: .increment)
#expect(sut.domain.count == 1)
}
@Test
func decrement() {
sut.interact(with: .decrement)
#expect(sut.domain.count == -1)
}
@Test
func reset() {
// First increment to change the initial state
sut.interact(with: .increment)
#expect(sut.domain.count == 1)
// Then reset
sut.interact(with: .reset)
#expect(sut.domain.count == 0)
}
@Test
func multipleActions() async throws {
// For complex sequences where you need to capture intermediate states,
// use the collect utility
let emittedDomains = try await sut.collect(
3,
performing: [
.action(.increment),
.action(.increment),
.action(.decrement)
]
)
let expectedDomains: [CounterDomain] = [
CounterDomain(count: 1),
CounterDomain(count: 2),
CounterDomain(count: 1)
]
#expect(emittedDomains == expectedDomains)
}
}Riveting is available under the MIT license. See the LICENSE file for more info.