A simple way to use sum types to guarantee your view models are always in a valid state Sobreiro helps binding views to models in a lightweight manner.
Besides dragging the sources, Sobreiro can be included via Swift Package Manager. A tool for automating distribution of Swift code, and is integrated into the swift compiler.
Go to File > Swift Packages > Add Package Dependency and enter
https://github.com/hydcozz/Sobreiro
Firstly, define possible states with a tagged union, conforming to ViewState
.
enum ListViewState: ViewState {
case loading
case loaded(Results)
case error(Error)
}
Secondly, implement your view model and its update commands, inheriting ViewModel
.
class ListViewModel: ViewModel<ListViewState> {
func startLoading() {
write {
switch state {
case .loading: return
case .loaded(let results): state = .loading(results)
case .error: state = .loading(nil)
}
}
}
func didLoad(results: Results) {
write {
state = .loaded(results)
}
}
func didFail(with error: Error) {
write {
switch state {
case .loading(let results): state = .error(error, results)
case .loaded(let results): state = .error(error, results)
case .error(_, let results): state = .error(error, results)
}
}
}
}
View model's state
must be updated with a call to write(_ transaction: () -> Void)
.
Finally, implement your views and/or view controllers, conforming to StatefulView
and subscribing to the view model.
class ListViewController: ViewController, StatefulView {
override func viewDidLoad() {
super.viewDidLoad()
viewModel.subscribe(view: self)
}
func render(state: ListViewState) {
// render state
}
}
Not all situations call for sum types. No reason to make your view state an enumeration if there's only one case to contemplate. However, it should not be a reference type.
Albeit not enforcing it, Sobreiro expects value types as view states. This guarantees the view model knows of any update.
struct MiniUserViewState: ViewState {
let image: Image?
let name: String
let role: Role
enum Role {
case sales
case support
case development
}
}
Sobreiro provides subscription methods, allowing you to break view models in reusable or shared components.
struct FrontRunnerViewState: ViewState {
let image: Image
let name: String
let points: Int
}
class FrontRunnerViewModel: ViewModel<FrontRunnerViewState> {
let miniUserViewModel: MiniUserViewModel
init(initialState: FrontRunnerViewState, miniUserViewModel: MiniUserViewModel) {
self.miniUserViewModel = miniUserViewModel
super.init(initialState: initialState)
self.subscribe(to: miniUserViewModel, onChange: miniUserDidChange)
}
private func miniUserDidChange(_ newState: MiniUserViewState) {
update {
FrontRunnerViewState(
image: newState.image ?? .noUserImage,
name: newState.name,
points: state.points
)
}
}
}
StatefulView
can be conformed by any class, but Sobreiro offers default render policies for native views and controller; so it may be easier to use them.
#if os(OSX)
typealias Image = NSImage
#elseif os(iOS) || os(tvOS)
typealias Image = UIImage
#endif
enum ProfilePhotoViewState: ViewState {
case online(Image)
case offline(Image)
}
class ProfilePhotoViewModel: ViewModel<ProfilePhotoViewState> {
// Implement commands…
}
#if os(OSX)
class ProfilePhotoView: NSView {
// Don't for get to subscribe!
// viewModel.subscribe(view: self)
}
#elseif os(iOS) || os(tvOS)
class ProfilePhotoView: UIView {
// Don't for get to subscribe!
// viewModel.subscribe(view: self)
}
#endif
extension ProfilePhotoView: StatefulView {
func render(state: ProfilePhotoViewState) {
// render state
}
}
The state must be set via a write or an update. In writes you'll set the state, if needed. Updates will set the state for you, via a builder you provide. All mutating transaction are run atomically, so you're guaranteed the state won't be changed while they're executing.
// inside your view model
func animate() {
write {
guard !state.isAnimating() else {
return
}
state = state.copyAnimating()
}
}
func setColour(_ colour: Colour) {
update {
return state.copyColoured(with: colour)
}
}
Stateful views subscribe to view models for their rendering to be trigger by state changes.
// inside your stateful view
viewModel.subscribe(view: self)
There's no need for unsubscribing stateful views when they're removed from memory, it's done automatically. You may nonetheless unsubscribe them yourself.
// inside your stateful view
var viewModel: ViewModel? {
willSet { viewModel?.unsubscribe(view: self) }
didSet { viewModel?.subscribe(view: self) }
}
When the view state changes, it'll trigger the rendering of all subscribed stateful views.
// inside your stateful view
func render(state: ViewState) {
// state has changed
}
You may wish to break a view model in reusable or shared components. If so, model subscription methods help binding view models to other view models.
// subscribe to a component
viewModel.subscribe(to: componentViewModel) { componentViewState in
// handle component change
}
There's no need for unsubscribing when your model's removed from memory, it's done automatically. You may nonetheless unsubscribe them yourself.
// unsubscribe from a component
viewModel.unsubscribe(from: componentViewModel)
This are a couple of recommendation to help you out:
Add queries to your state.
extension ListViewState {
func isLoading() -> Bool {
switch self {
case .loading: return true
case .loaded, .error: return false
}
}
func results() -> Results? {
switch self {
case .loading(let results): return results
case .loaded(let results): return results
case .error(_, let results): return results
}
}
func error() -> Error? {
switch self {
case .error(let error, _): return error
case .loaded, .loading: return nil
}
}
}
They can be used to assist rendering.
class ListViewController: ViewController, StatefulView {
func render(state: ListViewState) {
renderLoading(state.isLoading())
renderResults(state.results())
renderError(state.error())
}
func renderLoading(_ isLoading: Bool) {
if isLoading {
// animate activity indicator
} else {
// stop activity indicator
}
}
func renderResults(_ results: Results?) {
if let results = results {
// present results
} else {
// clear results
}
}
func renderError(_ error: Error?) {
if let error = error {
// present error
} else {
// clear error
}
}
}
Avoid rendering equal states by implementing the ==
operator.
extension ListViewState {
static func == (lhs: ListViewState, rhs: ListViewState) -> Bool {
switch (lhs, rhs) {
case (.loading(let lhs), .loading(let rhs)): return lhs == rhs
case (.loaded(let lhs), .loaded(let rhs)): return lhs == rhs
default: return false
}
}
}
Proper documentation, maybe?
This work was inspired by an article, written by Luis Recuenco at Jobandtalent Engineering, and titled iOS Architecture: A State Container based approach.
This work adapts Jobandtalent's for exclusive use with views, and thus making it a tad lighter.
Copyright (c) 2020 Tiago Rodrigues
Licensed under MIT License.
link |
Stars: 0 |
Last commit: 4 years ago |
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics