An idea I've had for improving ResultBuilders. I was curious if there are folks who have had the same pain point with ResultBuilders and if this would solve problems they might have had.
Motivation
It can be difficult to implement systems that operate on the output of ResultBuilders, because those systems need to re-run the body of the ResultBuilder whenever the inputs to the control flow change. This is because builderEither()
only gives the branch that is executed on the run of the ResultBuilder, instead of yielding both. Thus, implementors of such systems need to implement either diffing algorithms (potentially inefficient) or combine fixed return types with reflection on the return value of the body/associated type of a View
in SwiftUI's case (potentially inefficient, beyond the means of most non-Apple engineers, requires lots of boilerplate in the builder itself to simulate variadic generics).
Summary
This change would allow users to implement ResultBuilders that allow the following:
var fixedWidth: SomePublisher<Float>
@ConstraintBuilder
func makeConstraints() -> some Constraints {
EqualToSuperview {
Leading()
Trailing()
}
if let fixedWidth {
Width(equalTo: fixedWidth)
} else {
WrapContent()
}
}
var isFixedWidth: SomePublisher<Bool>
@ConstraintBuilder
func makeConstraints() -> some Constraints {
EqualToSuperview {
Leading()
Trailing()
}
if isFixedWidth {
Width(equalTo: 100)
} else {
WrapContent()
}
}
To support this, the following functions would be added to the ResultBuilder templating system:
// if-else
buildEither(_ stream: Stream, first: () -> First, second: () -> Second) -> MyResultType
// if-let
buildEither(_ stream: Stream, first: (Element) -> First, second: () -> Second) -> MyResultType
// when variadic generics are available, we could support multiple if-lets and switches, or they could be supported with the current approach used by SwiftUI
In summary, these ResultBuilder functions receive all branches of the control flow, not just the one that would be executed if the if statement had been evaluated. The if-statement/if-let binding doesn't operate on a boolean anymore, it instead operates on an arbitrary type, which in typical usages would be something resembling a Combine Publisher
, AsyncSequence
, or RxSwift Observable
.
An implementation of the if-else function might look like the following, for our prior example:
buildEither<First, Second>(_ stream: SomePublisher<Bool>, first: () -> First, second: () -> Second) -> ConditionalConstraint<First, Second> {
ConditionalConstraint(stream, first(), next())
}
struct ConditionConstraint<First, Second> {
let first: First
let second: Second
let stream: Stream
func apply(in parent: UIView, manager: ConstraintManager) {
manager.addSubscription(stream.recieve {
if $0 {
second.deactivate()
first.activate()
} else {
first.deactivate()
second.activate()
}
})
}
}
This design has one major advantage over the current ResultBuilder implementation: if the only mutable inputs to your system are streams, then you never need to re-run the body of your ResultBuilders, because everything about how they change is known at compile time.
Potential usability issues
The major disadvantage is that if you want inputs to functions you call inside the ResultBuilder to change, these are completely beyond the scope of the system to detect: for example:
var fixedWidth: SomePublisher<Float>
@ConstraintBuilder
func makeConstraints() -> some Constraints {
Width(equalTo: fixedWidth)
}
This wouldn't work, unless Width()
had an overload that took some Publisher<Float>
and subscribed to it internally. For a library like SwiftUI, I think this would be very painful to work with.
Alternative syntax
One other objection to this is that the overloading of control flow in this manner is confusing. For example, one potential abuse of this system would be to consider Bool?
a "stream" to avoid writing out ?? false
in a result builder:
buildEither(_ stream: Bool?, first: () -> First, second: () -> Second) -> MyResultType {
if stream ?? false {
return first()
} else {
return second()
}
}
A potential solution would be change the design of this system to require that the Stream was an AsyncSequence
and then the syntax could be
if await x {
...
}
if await let x {
...
}
Thanks for reading. I personally am not sure if this design is a good idea, and I'm hoping that feedback from folks here will give the answer to that.