iosswiftswiftuiobservable

How to bind a property from ParentViewModel to a property of a ChildViewModel


I want to know what's the most straightforward way to bind a property (age) from a parent ViewModel (ProfileViewModel) to a child ViewModel (AgePickerViewModel) using the @Observable macro.

I have a working example below, but it's silly to manage a local @Binding in the View struct together with .onChange(of:. I was unable to get it working that I can pass the age: Binding<Int> directly into the AgePickerViewModel initializer.

Is there a better way to accomplish that, than my example?

Disclaimer: This is a minimal example, just to visualize and name things. Imagine the AgePickerView has a larger logic to pick the age, large enough to bundle it inside a view model. Please don't focus on this exact example

import SwiftUI

#Preview {
    ProfileView()
}

// Parent ViewModel
@Observable
class ProfileViewModel {
    var name: String = "John"
    var age: Int = 30
}

// Child ViewModel with its own responsibility
@Observable
class AgePickerViewModel {
    var age: Int
    var minAge: Int = 1
    var maxAge: Int = 120
    
    init(age: Int) {
        self.age = age
    }
}

// Parent View
struct ProfileView: View {
    @Bindable var viewModel = ProfileViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Profile: \(viewModel.name)")
                .font(.title)
            
            Text("Age: \(viewModel.age)")
                .font(.headline)
            
            // Passing the age binding to the child view
            AgePickerView(age: $viewModel.age)
                .frame(height: 200)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
        }
        .padding()
    }
}

// Child View with its own ViewModel
struct AgePickerView: View {
    // Child has its own ViewModel
    var viewModel: AgePickerViewModel
    
    // Takes a binding to the parent's age property
    init(age: Binding<Int>) {
        self.viewModel = AgePickerViewModel(age: age.wrappedValue)
        _boundAge = age
    }
    
    // Binding to the external value
    @Binding private var boundAge: Int
    
    var body: some View {
        VStack {
            Text("Select Age")
                .font(.headline)
                .padding(.bottom, 5)
            
            Picker("Age", selection: $boundAge) {
                ForEach(viewModel.minAge...viewModel.maxAge, id: \.self) { age in
                    Text("\(age)").tag(age)
                }
            }
            .pickerStyle(.wheel)
            .onChange(of: boundAge) { _, newValue in
                // Update the child ViewModel when the bound value changes
                viewModel.age = newValue
            }
        }
    }
}

So I tried to pass the Binding<Int> until it got inside the child ViewModel and there it loses the connection when assigning it with .wrappedValue.

This code compiles, but the parent view does not get any updates.

import SwiftUI

#Preview {
    ProfileView()
}

@Observable
class ProfileViewModel {
    var name: String = "John"
    var age: Int = 30
}

@Observable
class AgePickerViewModel {
    var age: Int
    var minAge: Int = 1
    var maxAge: Int = 120
    
    init(age: Binding<Int>) {
        // HOW TO: Use Binding in an Observable class?
        self.age = age.wrappedValue
    }
}

struct ProfileView: View {
    @Bindable var viewModel = ProfileViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Profile: \(viewModel.name)")
                .font(.title)
            
            Text("Age: \(viewModel.age)")
                .font(.headline)
            
            AgePickerView(age: $viewModel.age)
                .frame(height: 200)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
        }
        .padding()
    }
}

struct AgePickerView: View {
    @Bindable var viewModel: AgePickerViewModel
    
    init(age: Binding<Int>) {
        // Pass to viewModel
        self.viewModel = AgePickerViewModel(age: age)
    }
    
    var body: some View {
        VStack {
            Text("Select Age")
                .font(.headline)
                .padding(.bottom, 5)
            
            Picker("Age", selection: $viewModel.age) {
                ForEach(viewModel.minAge...viewModel.maxAge, id: \.self) { age in
                    Text("\(age)").tag(age)
                }
            }
            .pickerStyle(.wheel)
        }
    }
}

Solution

  • The comments from lorem ipsum brought me in the right direction.

    And probably the example I posted was confusing because it's too simple and does not exactly show the relationship between parent and child view.

    A bit more explanation about the example: The parent (ProfileView) wants to know the age from the child (AgePickerView) and also gives an initial value to display it. But the child view requires a complex logic to calculate the age, so it makes sense to have a view model in the child.

    My initial thoughts: I have to use a Binding from parent to child because it is bidirectional communication: the parent tells the initial value and the child tells the calculated value.

    What I learned: In this case, using a Closure is more appropriate than a Binding because the child view owns the logic and only needs to notify the parent once the result is available. The parent provides an initial value, but after that, there’s no shared or continuously synchronized state.

    Here is a similar, but better example:

    import SwiftUI
    
    #Preview {
        ProfileView()
    }
    
    // MARK: - Parent ViewModel
    @Observable
    class ProfileViewModel {
        var name: String = "John"
        var age: Int = 30
    }
    
    // MARK: - Child ViewModel with calculation logic
    @Observable
    class AgePickerViewModel {
        var age: Int
        var isLoading = false
        
        init(age: Int) {
            self.age = age
        }
        
        // Pretend this is some complex async logic (e.g. network, date of birth, etc.)
        func calculateAge() async -> Int {
            isLoading = true
            try? await Task.sleep(nanoseconds: 1_000_000_000) // simulate delay
            isLoading = false
            return Int.random(in: 20...60) // simulated calculated age
        }
    }
    
    // MARK: - Parent View
    struct ProfileView: View {
        var viewModel = ProfileViewModel()
        
        var body: some View {
            VStack(spacing: 20) {
                Text("Profile: \(viewModel.name)")
                    .font(.title)
                
                Text("Age: \(viewModel.age)")
                    .font(.headline)
                
                AgePickerView(
                    initialAge: viewModel.age,
                    onAgePicked: { newAge in
                        viewModel.age = newAge
                    }
                )
                .frame(height: 200)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
            }
            .padding()
        }
    }
    
    // MARK: - Child View
    struct AgePickerView: View {
        private var viewModel: AgePickerViewModel
        let onAgePicked: (Int) -> Void
        
        init(initialAge: Int, onAgePicked: @escaping (Int) -> Void) {
            viewModel = AgePickerViewModel(age: initialAge)
            self.onAgePicked = onAgePicked
        }
        
        var body: some View {
            VStack(spacing: 16) {
                Text("Age Estimation")
                    .font(.headline)
                
                if viewModel.isLoading {
                    ProgressView("Calculating age…")
                } else {
                    Button("Estimate Age") {
                        Task {
                            let result = await viewModel.calculateAge()
                            viewModel.age = result
                            onAgePicked(result)
                        }
                    }
                }
                
                Text("Result: \(viewModel.age)")
                    .foregroundColor(.gray)
            }
        }
    }