I guess this is one way to do it.
TLDR;
- Create a resolver view to wrap your content views.
- Use an environment closure to pullback values from it.
- Re-distribute these values using the
containerValue
API to propagate value to subviews. - Use
.onChange(of: subview.containerValues.foo)
API on subview to be notified of changes.
Let's say we want to design an API like that:
// MARK: Client
Stepper {
StepView {
Text("Becoming spartan")
} content: {
FooView()
}
StepView {
Text("Dying in glory")
} content: {
BarView()
}
}
where FooView
, BarView
could access to a complete closure through environment to notify it finishes (same way as @Environment(\.dismiss)
):
struct FooView: View {
@Environment(\.isComplete) private var isComplete
var body: some View {
VStack {
Text("This is the part where you become a spartan")
HStack(alignment: .bottom) {
Rectangle()
.fill(Color.blue.gradient)
.frame(width: 50, height: 25)
Rectangle()
.fill(Color.cyan.gradient)
.frame(width: 50, height: 10)
Rectangle()
.fill(Color.pink.gradient)
.frame(width: 50, height: 30)
}
Button {
isComplete()
} label: {
Text("This is Sparta")
}
}
}
}
Client could add as many step as he wants adding more and more views to the Stepper

// MARK: Library
struct Stepper<Content: View>: View {
@State private var steps = [StepState()]
@ViewBuilder let content: Content
var body: some View {
VStack(spacing: 30) {
Group(subviews: content) { subviews in
ForEach(Array(zip(steps.indices, subviews)), id: \.0) { index, subview in
subview
.onChange(of: subview.containerValues.isComplete) { oldValue, newValue in
$steps[index].wrappedValue.isComplete = newValue
}
}
.onAppear {
self.steps = Array(repeating: StepState(), count: subviews.count)
}
}
}
}
}
struct StepView<Title: View, Info: View>: View {
@ViewBuilder var titleContent: Title
@ViewBuilder var content: () -> Info
@State
private var isComplete = false
@State
private var isExpanded = false
var body: some View {
VStack {
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
HStack {
titleContent
Spacer()
if isComplete {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 24, height: 24)
.foregroundStyle(.green)
}
Image(systemName: "chevron.right")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.rotationEffect(isExpanded && !isComplete ? .degrees(-90) : .degrees(90))
}
}
.disabled(isComplete)
if isExpanded, !isComplete {
content()
.environment(\.isComplete, {
withAnimation {
isComplete = true
}
})
.transition(
.asymmetric(
insertion: .opacity.animation(.linear.delay(0.33)),
removal: .identity
)
)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.white)
.shadow(radius: 5)
)
.containerValue(\.isComplete, isComplete)
}
}
struct StepState: Equatable {
var isComplete = false
}
extension EnvironmentValues {
@Entry var isComplete: () -> Void = { print("Hello World!") }
}
extension ContainerValues {
@Entry var isComplete: Bool = false
}