VSM is a reactive architecture that is unidirectional, highly type-safe, behavior-driven, and clean. This repository hosts an open-source swift package framework for easily building features in VSM on iOS.
VSM stands for both "View State Model" and "Viewable State Machine". The first definition describes how a feature in VSM is structured, the second definition illustrates how information flows.
)
In VSM, the View renders the State. Each state may provide a Model. Each model contains the data and actions available in a given state. Each action in a model returns one or more new states. Any state changes will update the view.
The following are code excerpts of a feature that shows a blog entry from a data repository.
The state is usually defined as an enum or a struct and represents the states that the view can have. It also declares the data and actions available to the view for each model. Actions return one or more new states.
enum BlogEntryViewState {
case initialized(loaderModel: LoaderModeling)
case loading(errorModel: ErrorModeling?)
case loaded(blogModel: BlogModeling)
}
protocol LoaderModeling {
func load() -> AnyPublisher<BlogArticleViewState, Never>
}
protocol ErrorModeling {
var message: String { get }
func retry() -> AnyPublisher<BlogArticleViewState, Never>
}
protocol BlogModeling {
var title: String { get }
var text: String { get }
func refresh() -> AnyPublisher<BlogArticleViewState, Never>
}
The discrete models provide the data for a given view state and implement the business logic within the actions.
struct LoaderModel: LoaderModeling {
func load() -> AnyPublisher<BlogArticleViewState, Never> {
...
}
}
struct ErrorModel: ErrorModeling {
var message: String
func retry() -> AnyPublisher<BlogArticleViewState, Never> {
...
}
}
struct BlogModel: BlogModeling {
var title: String
var body: String
func refresh() -> AnyPublisher<BlogArticleViewState, Never> {
...
}
}
The view observes and renders the state using the ViewState
property wrapper. State changes will automatically update the view.
struct BlogEntryView: View {
@ViewState var state: BlogEntryViewState
init() {
_state = .init(wrappedValue: .initialized(LoaderModel()))
}
var body: some View {
switch state {
case .initialized(loaderModel: let loaderModel):
...
.onAppear {
$state.observe(loaderModel.load())
}
case .loading(errorModel: let errorModel):
...
case .loaded(blogModel: let blogModel)
...
Button("Reload") {
$state.observe(blogModel.refresh())
}
}
}
}
This example uses SwiftUI, but the framework is also designed to work seamlessly with UIKit.
For more detailed tutorials and documentation, visit the VSM Documentation
VSM for iOS is owned and maintained by Wayfair.
See CONTRIBUTING.md.
See SECURITY.md.
VSM for iOS is released under the MIT license. See LICENSE for details.
link |
Stars: 5 |
Last commit: 14 hours ago |
VSM has reached v1.0 after 8 months of use in high-traffic production code, several solidifying bug fixes, and incremental improvements to the ergonomics of the framework.
This release introduces a new, preferred way of working with VSM through property wrappers. (Proposal)
Example Usage of @ViewState
(SwiftUI only)
// SwiftUI
struct MyView: View {
@ViewState var state: MyViewState = .initialized(LoaderModel())
var body: some View {
switch state {
case .initialized(let loaderModel):
ProgressView()
.onAppear {
$state.observe(loaderModel.load())
}
case .loading:
ProgressView()
case .error(let errorModel):
Text(errorModel.message)
Button("Retry") {
$state.observe(errorModel.retry())
}
case .loaded(let contentModel):
Text(contentModel.details)
}
}
}
Example Usage of @RenderedViewState
(UIKit only)
// UIKit
class MyViewController: UIViewController {
// view properties
@RenderedViewState(MyViewController.render)
var state: MyViewState = .initialized(LoaderModel())
func viewDidLoad() {
super.viewDidLoad()
if case .initialized(let loaderModel) = state {
$state.observe(loaderModel.load())
}
let action = UIAction() { [weak self] action in
guard let strongSelf = self else { return }
guard case .error(let errorModel) = strongSelf.state else { return }
$state.observe(errorModel.retry())
}
errorButton.addAction(action, for: .touchUpInside)
}
func render() {
switch state {
case .initialized, .loading:
loadingIndicator.isHidden = false
errorView.isHidden = true
contentView.isHidden = true
case .error(let errorModel):
loadingIndicator.isHidden = true
errorView.isHidden = false
errorMessage.text = errorModel.message
contentView.isHidden = true
case .loaded(let contentModel):
loadingIndicator.isHidden = true
errorView.isHidden = true
contentView.isHidden = false
contentLabel.text = contentModel.details
}
}
}
The StateContainer
type now provides a publisher of the current State
. You can access it via $state.publisher
when using view state property wrappers or via statContainer.publisher
if using a StateContainer directly.
This new publisher will emit new state values on didSet
, as opposed to stateContainer.$state
which emits values on willSet
.
The debug logging functionality has been updated and can be configured to output different formats and events. You can access it statically for all state containers or individually per state container.
Static Usage
// prints the state changes for all VSM views in the app
ViewState._debug(options: [...])
// or
RenderedViewState._debug(options: [...])
// or
StateContainer._debug(options: [...])
Local Usage
// prints the current state and future state updates for this VSM view
$state._debug(options: [...])
// or
stateContainer._debug(options: [...])
The new SwiftUI property wrapper helps avoid certain data operation and state pitfalls.
In previous versions, the VSM framework allowed engineers to perform data operations within SwiftUI initializers like so:
init() {
let loaderModel = LoaderModel()
_container = .init(state: .initialized(loaderModel))
container.observe(loaderModel.load())
}
This anti-pattern most often results in undesired behavior or data operations because SwiftUI View initializers can be called any number of times.
The new @ViewState
property wrapper will now emit a runtime warning if you attempt to observe the state from within the initializer.
init() {
let loaderModel = LoaderModel()
_state_ = .init(state: .initialized(loaderModel))
$state.observe(loaderModel.load()) // runtime warning about accessing the value of a state object before it is assigned to a view
}
This encourages engineers to use the onAppear
view modifier instead, which is the recommended approach for SwiftUI:
ProgressView()
.onAppear {
if case .initialized(let loaderModel) = state {
$state.observe(loaderModel.load())
}
}
Note: In SwiftUI,
onAppear
is guaranteed to execute before the first frame is drawn. Any synchronous changes to@ViewState
,@State
,@ObservedObject
, or@StateObject
properties will cause the view'sbody
to be reevaluated before the first frame is drawn.
observe({ await foo() })
to observeAsync({ await foo() })
observe(...)
function now favors staying on the main thread where possible. If you need main thread async-execution, then you must use .subscribe(on: DispatchQueue.main)
explicitly within your model actions.observe(...)
function overloads no longer accept closures or function types as parameters.observe(somePublisher)
to ensure main-thread execution for synchronous execution paths.ViewStateRendering
conformance from your Views, UIViews, or UIViewControllers.Note: The
ViewStateRendering
protocol has been deprecated. It will be removed in a future version of the VSM framework. Its usage will produce compiler warnings.
@StateObject var container: StateContainer<FooViewState>
with @ViewState var state: FooViewState
.container.state
with state
.observe(...)
or container.observe(...)
with $state.observe(...)
.onAppear
closure of an appropriate subview.var container: StateContainer<FooViewState>
with either:
@RenderedViewState var state: FooViewState
@RenderedViewState(MyViewController.render) var state: FooViewState = .fooState(BarModel())
container.state
with state
.observe(...)
or container.observe(...)
with $state.observe(...)
.func render() { ... }
.stateSubscription = container.$state.sink { ... }
)Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics