Conditionals: EitherView and OptionalView with reactive branch visibility
This commit is contained in:
parent
c309fc3d1a
commit
d177a85236
6 changed files with 308 additions and 5 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
Sources/Portico/Containers/EitherView.swift
Normal file
70
Sources/Portico/Containers/EitherView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
41
Sources/Portico/Containers/OptionalView.swift
Normal file
41
Sources/Portico/Containers/OptionalView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)] }
|
||||
|
|
|
|||
168
Tests/PorticoTests/ConditionalTests.swift
Normal file
168
Tests/PorticoTests/ConditionalTests.swift
Normal 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: true→false→true→false
|
||||
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue