Conditionals: EitherView and OptionalView with reactive branch visibility

This commit is contained in:
Brendan Szymanski 2026-07-23 22:35:34 -04:00
parent c309fc3d1a
commit d177a85236
6 changed files with 308 additions and 5 deletions

View file

@ -14,13 +14,27 @@ struct Counter: View {
}
}
struct ToggleDemo: View {
@State private var show = true
var body: some View {
VStack(spacing: 12) {
Button("Toggle") { show.toggle() }
EitherView($show,
first: { Label("First branch — visible") },
second: { Label("Second branch — hidden") }
)
}
}
}
@main
struct ExampleApp: App {
var applicationId: String? { "dev.bscubed.PorticoExample" }
var body: some Scene {
Window { _ in Counter() }
.title("Portico P1")
Window { _ in ToggleDemo() }
.title("Portico P2")
.defaultSize(width: 1280, height: 800)
}
}

View file

@ -0,0 +1,70 @@
import Gtk
/// A view that switches between two branches based on a `Binding<Bool>` condition.
///
/// Internally uses `Gtk.Stack` with named children `"true"` and `"false"`.
/// Each branch's widgets are mounted at most once the first time that
/// branch becomes active. Subsequent condition changes only call
/// `setVisibleChildName`.
@MainActor public struct EitherView: View {
private let condition: Binding<Bool>
private let firstContent: [AnyView]
private let secondContent: [AnyView]
public var body: Never { fatalError() }
/// - Parameters:
/// - condition: The binding that selects the visible branch.
/// - first: The view for the `true` branch (mounted once, lazily).
/// - second: The view for the `false` branch (mounted once, lazily).
public init(
_ condition: Binding<Bool>,
@ViewBuilder first: () -> [AnyView],
@ViewBuilder second: () -> [AnyView]
) {
self.condition = condition
self.firstContent = first()
self.secondContent = second()
}
}
@_spi(Portico) extension EitherView: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let stack = Gtk.Stack()
// Branch widget caches nil until the branch is first mounted.
var trueWidgets: [Gtk.Widget]? = nil
var falseWidgets: [Gtk.Widget]? = nil
// Mount the initially-active branch first so it's visible immediately.
if condition.wrappedValue {
trueWidgets = firstContent.map { $0.makeWidget(ctx) }
for w in trueWidgets! { _ = stack.addNamed(child: w, name: "true") }
} else {
falseWidgets = secondContent.map { $0.makeWidget(ctx) }
for w in falseWidgets! { _ = stack.addNamed(child: w, name: "false") }
}
// Track changes re-evaluate condition, lazily mount the other
// branch on first flip.
let tracker = DependencyTracker { [stack] in
let isTrue = condition.wrappedValue
if isTrue {
if trueWidgets == nil {
trueWidgets = firstContent.map { $0.makeWidget(ctx) }
for w in trueWidgets! { _ = stack.addNamed(child: w, name: "true") }
}
stack.setVisibleChildName(name: "true")
} else {
if falseWidgets == nil {
falseWidgets = secondContent.map { $0.makeWidget(ctx) }
for w in falseWidgets! { _ = stack.addNamed(child: w, name: "false") }
}
stack.setVisibleChildName(name: "false")
}
}
tracker.run()
return stack
}
}

View file

@ -0,0 +1,9 @@
import Gtk
@_spi(Portico) extension Gtk.Revealer: SingleChildContainer {
/// Delegates to `Gtk.Revealer.setChild(child:)` with the distinct SPI
/// name to support nullable child semantics (nil detaches).
@_spi(Portico) public func attachChild(_ child: Gtk.Widget?) {
setChild(child: child)
}
}

View file

@ -0,0 +1,41 @@
import Gtk
/// A view that shows or hides its child based on a `Binding<Bool>` condition.
///
/// Internally uses `Gtk.Revealer`. The child widget is mounted exactly once;
/// subsequent condition changes only toggle `setRevealChild`.
@MainActor public struct OptionalView: View {
private let condition: Binding<Bool>
private let content: [AnyView]
public var body: Never { fatalError() }
/// - Parameters:
/// - condition: The binding that controls visibility.
/// - content: The view to conditionally reveal (mounted once).
public init(
_ condition: Binding<Bool>,
@ViewBuilder content: () -> [AnyView]
) {
self.condition = condition
self.content = content()
}
}
@_spi(Portico) extension OptionalView: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let revealer = Gtk.Revealer()
// Mount child once build-once invariant.
for child in content {
revealer.attachChild(child.makeWidget(ctx))
}
// Apply initial state.
revealer.setRevealChild(revealChild: condition.wrappedValue)
// Track changes.
let tracker = DependencyTracker { [revealer] in
revealer.setRevealChild(revealChild: condition.wrappedValue)
}
tracker.run()
return revealer
}
}

View file

@ -1,8 +1,9 @@
/// Result builder for composing ``View`` hierarchies.
///
/// Conditionals (`buildOptional`/`buildEither`) are passthrough in P0
/// (they collapse to plain arrays) correct for the static-only scope.
/// P2 replaces them to emit `OptionalView`/`EitherView` nodes.
/// `buildOptional`/`buildEither` are static-only passthroughs
/// they collapse to plain arrays. Dynamic conditionals use the
/// explicit ``EitherView`` and ``OptionalView`` types which accept
/// a `Binding<Bool>` and mount branches via `Gtk.Stack`/`Gtk.Revealer`.
@resultBuilder public enum ViewBuilder {
/// Wraps a single ``View`` in an ``AnyView``.
public static func buildExpression<V: View>(_ v: V) -> [AnyView] { [AnyView(v)] }

View file

@ -0,0 +1,168 @@
import Testing
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@MainActor @Suite(.serialized) struct ConditionalTests {
// MARK: - EitherView
@Test func eitherViewInitiallyTrueBranch() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(true)
let binding = Binding(box)
let view = EitherView(binding,
first: { FirstProbeLabel() },
second: { SecondProbeLabel() }
)
let widget = AnyView(view).makeWidget(MountContext())
let stack = widget as! Gtk.Stack
// Initially, only the "true" branch is mounted and visible.
#expect(stack.getVisibleChildName() == "true")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 0)
// Toggle to false lazy-mounts the "false" branch.
box.set(false)
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
// Toggle back to true no new mounts.
box.set(true)
#expect(stack.getVisibleChildName() == "true")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
}
@Test func eitherViewInitiallyFalseBranch() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(false)
let binding = Binding(box)
let view = EitherView(binding,
first: { FirstProbeLabel() },
second: { SecondProbeLabel() }
)
let widget = AnyView(view).makeWidget(MountContext())
let stack = widget as! Gtk.Stack
// Initially, only the "false" branch is mounted and visible.
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 0)
#expect(ConditionProbe.secondCount == 1)
// Toggle to true lazy-mounts the "true" branch.
box.set(true)
#expect(stack.getVisibleChildName() == "true")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
// Toggle back to false no new mounts.
box.set(false)
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
}
@Test func eitherViewTracksConditionChanges() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(true)
let binding = Binding(box)
let view = EitherView(binding,
first: { FirstProbeLabel() },
second: { SecondProbeLabel() }
)
let widget = AnyView(view).makeWidget(MountContext())
let stack = widget as! Gtk.Stack
// Three toggles: truefalsetruefalse
box.set(false)
box.set(true)
box.set(false)
#expect(stack.getVisibleChildName() == "false")
#expect(ConditionProbe.firstCount == 1)
#expect(ConditionProbe.secondCount == 1)
}
// MARK: - OptionalView
@Test func optionalViewMountsChildOnce() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(true)
let binding = Binding(box)
let view = OptionalView(binding) { FirstProbeLabel() }
let widget = AnyView(view).makeWidget(MountContext())
let revealer = widget as! Gtk.Revealer
// Initially revealed, child mounted once.
#expect(revealer.getRevealChild() == true)
#expect(ConditionProbe.firstCount == 1)
// Toggle to false child still mounted, just hidden.
box.set(false)
#expect(revealer.getRevealChild() == false)
#expect(ConditionProbe.firstCount == 1)
// Toggle back to true still same child.
box.set(true)
#expect(revealer.getRevealChild() == true)
#expect(ConditionProbe.firstCount == 1)
}
@Test func optionalViewHidesWhenConditionFalse() {
guard Gtk.initCheck() else { return }
ConditionProbe.reset()
let box = StateBox(false)
let binding = Binding(box)
let view = OptionalView(binding) { FirstProbeLabel() }
let widget = AnyView(view).makeWidget(MountContext())
let revealer = widget as! Gtk.Revealer
// Initially hidden, but child WAS mounted (build-once).
#expect(revealer.getRevealChild() == false)
#expect(ConditionProbe.firstCount == 1)
// Toggle to true child visible, no remount.
box.set(true)
#expect(revealer.getRevealChild() == true)
#expect(ConditionProbe.firstCount == 1)
}
}
// MARK: - Test helpers
@MainActor fileprivate enum ConditionProbe {
static var firstCount = 0
static var secondCount = 0
static func reset() {
firstCount = 0
secondCount = 0
}
}
@MainActor fileprivate struct FirstProbeLabel: View, Mountable {
var body: Never { fatalError() }
func mount(_ ctx: MountContext) -> Gtk.Widget {
ConditionProbe.firstCount += 1
return Gtk.Label(str: "first")
}
}
@MainActor fileprivate struct SecondProbeLabel: View, Mountable {
var body: Never { fatalError() }
func mount(_ ctx: MountContext) -> Gtk.Widget {
ConditionProbe.secondCount += 1
return Gtk.Label(str: "second")
}
}