When using Combine, a challenge sometimes is: How can a Publisher's current value be retrieved?
Combine.CurrentValueSubject would be an option since it stores a current value, but this is a dead end - you cannot map() a CurrentValueSubject and retrieve the mapped current value. It would also sometimes be desirable to make a subject immutable.
Properties (inspired by ReactiveSwift) fill these gaps. They take either a CurrentValueSubject or an initial value and a Publisher, and provide:
- The current value
- A
Publisherthat returns all future values, including the current one - A
Publisherthat returns all future values, excluding the current one - Operators to transform to other Properties (map, flatMap, boolean and/or/negate,...)
There are at least two scenarios where Properties shine:
- Slicing state (e.g.
AppState) into sub-states (e.g.AuthState) for easier testing. Maybe a ViewModel only needs theAuthStateto operate - if it gets passed theAppState, then the whole state has to be mocked for every test. To solve this, aProperty<AppState>can hold the current (and observable) root app state, andProperty<AuthState>can be derived by callingmapon the former. This same principle can be be applied per feature, or for reusable components, too — a UICollectionView cell could be passed a property, and subscribe to that property's changes. - Storing dynamic state in a value type (
struct/enum).Propertyis thread-safe and well-tested (its core functionality relies onCurrentValueSubject), so it's an excellent fit for internal storage. That way, even ViewModels can become value types (see example below), because mutability is pushed down to the properties.
Here's a not-so-academical example of how Properties can be used:
struct ViewModel {
let viewState: Property<ViewState>
let cellContents: Property<[CellContent]>
let isActivityIndicatorVisible: Property<Bool>
let emptyListPlaceholder: Property<String?>
init(
loadImpulses: PassthroughSubject<APIParams, Never>,
fetchFromAPI: @escaping (APIParams) -> AnyPublisher<APIResult, APIError>
) {
let loadingStates = loadImpulses
.flatMap { params -> AnyPublisher<ViewState, Never> in
let loadingState = Just(ViewState.loading(earlierResult: nil))
let fetchStates = fetchFromAPI(params)
.map(ViewState.loaded)
.catch { Just(ViewState.error($0)) }
return loadingState.append(fetchStates).eraseToAnyPublisher()
}
viewState = Property(initial: .initial, then: loadingStates)
isActivityIndicatorVisible = viewState.map {
switch $0 {
case .initial, .loading(.none):
return true
case .loading, .loaded, .error:
return false
}
}
cellContents = viewState.map {
switch $0 {
case let .loaded(result), let .loading(earlierResult: .some(result)):
return result.lines
case .initial, .error, .loading:
return []
}
}
emptyListPlaceholder = viewState.map {
switch $0 {
case let .loaded(result), let .loading(earlierResult: .some(result)):
return result.lines.isEmpty ? "No items found" : nil
case .initial, .error, .loading:
return nil
}
}
}
}
enum ViewState {
case initial
case loading(earlierResult: APIResult?)
case loaded(APIResult)
case error(APIError)
}
// Simple placeholder types so the example compiles:
struct APIResult {
let lines: [String]
}
typealias CellContent = String
typealias APIParams = Int
enum APIError: Error {
case wrapped(Error)
}This is an early beta. Crucial tests exist now, e.g. for lifetime, and the central flatMap operator. All public components and methods are documented.
I'm happy to accept pull requests, and my DMs are open @manuelmaly.
- Fixed lifetime problems for when a Property is not retained, but its
Publishers are still being observed. - Added crucial tests for Property lifetime and
flatMap - Added documentation to all public-facing components and methods
- Added operators:
mapwith 1, 2, or 3 keypathsmapwith a fixed valuefiltercompactMapzipwith one Property
- Initial implementation. Early alpha.