Last active
June 30, 2023 16:08
-
-
Save jakehawken/341a77f01d75e153037a42bd8b122bf5 to your computer and use it in GitHub Desktop.
ScreenStateHandler -- The glue to connect your views and presenters.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
/* | |
I love the presenter pattern, but I hate connecting the presenter to the view using | |
delegation. Since protocol conformance can't be private in Swift, delegation always | |
leaks encapsulation because the conforming type has now announced to the world that | |
it has methods and properties that are irrelevant to everyone but the type doing the | |
delegating. ScreenStateHandler is a skinny little intermediary object that handles a | |
delegation-style 1:1 relationship between a presenter and a view but can be composed | |
into (rather than bolted onto) the types in question. | |
*/ | |
/// A type for representing all possible states of a given view. The `newData` case for fully-loaded | |
/// states, and the `leeo` case for all other states, e.g. loading and error states. | |
enum ViewState<ViewModel, LeeoModel: LEEO> { | |
/// Represents all fully loaded data states. A 'ViewModel' in this case represents a | |
case newData(ViewModel) | |
/// Catch-all for non-happy path states. LEEO stands for "loading, error, empty, or offline" | |
case leeo(LeeoModel) | |
typealias Callback = (Self) -> Void | |
} | |
protocol LEEO { | |
var isLoading: Bool { get } | |
} | |
/// A type for gluing views and their presenter objects together while maintaining encapsulation. | |
/// | |
/// The existence of this intermeidary allows the implementations of both the view and its presenter to be | |
/// fully encapsulated. Since there is no way to privately conform to a protocol in Swift, the delegation | |
/// pattern exposes functionality on a delegate that is only relevant to whatever is doing the delegating. | |
/// | |
/// This type allows for a pattern similar to delegation without leaking encapsulation. The presenter and the | |
/// ViewModelHandler can both be comfortably composed into the view without delegate methods being exposed to | |
/// any other types. | |
class ViewStateHandler<ViewModel, LeeoModel: LEEO> { | |
/// The enumeration of possible states of the view. | |
typealias State = ViewState<ViewModel, LeeoModel> | |
/// The closure type used when new view states are published. | |
typealias Callback = State.Callback | |
private var callback: Callback = { _ in } | |
} | |
// MARK: - Publishing | |
extension ViewStateHandler { | |
/// Publishes a new `State` to be consumed by the view. | |
/// Mean to be called by the presenter. | |
func publishState(_ state: State) { | |
DispatchQueue.main.async { [weak self] in | |
self?.callback(state) | |
} | |
} | |
/// Convenience method for publishing a `ViewModel`. | |
func publishViewModel(_ newData: ViewModel) { | |
publishState(.newData(newData)) | |
} | |
/// Convenience method for publishing a `LeeoModel`. | |
func publishLeeo(_ leeo: LeeoModel) { | |
publishState(.leeo(leeo)) | |
} | |
} | |
// MARK: - Subscribing | |
extension ViewStateHandler { | |
/// Sets the callback to be called when new states are published. | |
/// Usually called by the view or as passthrough method on the | |
/// presenter for giving a callback from the handler back to the view. | |
func onNewState(callback newCallback: @escaping Callback) { | |
callback = newCallback | |
} | |
} | |
extension ViewState { | |
/// Executes the given closure synchronously if and only if it's in the `newData` state. To use when | |
/// the consumer (the view) only cares about happy path states. | |
@discardableResult func onNewModel(action: @escaping (ViewModel) -> ()) -> Self { | |
switch self { | |
case .newData(let model): | |
action(model) | |
default: | |
break | |
} | |
return self | |
} | |
/// Executes the given closure synchronously if and only if it's in the `leeo` state. To use when | |
/// the consumer (the view) only cares about error path states. | |
@discardableResult func onLeeoState(action: @escaping (LeeoModel) -> ()) -> Self { | |
switch self { | |
case .leeo(let model): | |
action(model) | |
default: | |
break | |
} | |
return self | |
} | |
} | |
// MARK: - defaults | |
/// A default LEEO type for when a bespoke type is not needed. | |
enum DefaultLeeo: LEEO { | |
/// The state of a view while its data is being loaded. | |
case loading | |
/// A valid empty state for the view (e.g. an empty inbox). | |
case empty | |
/// A state of the view when a failure has occurred (e.g. API errors). | |
case error(ErrorState) | |
/// A state representing the lack of a network connection to the data needed for this view. | |
case offline | |
/// Defines the nature of the error encountered. | |
enum ErrorState: Error { | |
/// Represents that the action needs a valid logged in state to continue. | |
case needsLogin | |
/// The catch-all state. Used for populating some kind of error alert view. | |
case other(alert: (title: String, message: String)?) | |
/// Convenience property for determining if login is | |
/// needed, to avoid having to use a switch statement. | |
var needsLogin: Bool { | |
switch self { | |
case .needsLogin: | |
return true | |
default: | |
return false | |
} | |
} | |
} | |
/// Convenience property for determining if login is | |
/// needed, to avoid having to use a switch statement. | |
var needsLogin: Bool { | |
switch self { | |
case .error(let error): | |
return error.needsLogin | |
default: | |
return false | |
} | |
} | |
/// Convenience property for determining if the view needs to show | |
/// a loading state to avoid having to use a switch statement. | |
var isLoading: Bool { | |
switch self { | |
case .loading: | |
return true | |
default: | |
return false | |
} | |
} | |
} | |
/// A protocol for reducing boilerplate when working with view models, | |
/// and `ViewModelHandler`s. Providing helpful typealiases and | |
protocol ViewStateConvertible { | |
associatedtype LeeoType: LEEO | |
typealias State = ViewState<Self, LeeoType> | |
typealias StateCallback = State.Callback | |
static func viewModelHandler() -> ViewStateHandler<Self, LeeoType> | |
} | |
/// Default implementation of the only required method so that all a view | |
/// model must to is simply nominally conform and they will get this functionality. | |
extension ViewStateConvertible { | |
static func viewModelHandler() -> ViewStateHandler<Self, DefaultLeeo> { | |
return .init() | |
} | |
} | |
/// A convenience protocol, conforming `ViewStateConvertible` | |
/// for all views that use the default `LEEO` type. | |
protocol DefaultViewStateConvertible: ViewStateConvertible where LeeoType == DefaultLeeo { } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment