'SwiftUI - Changing parent @State doesn't update child View

Specific question:

SwiftUI doesn't like us initializing @State using parameters from the parent, but what if the parent holding that @State causes major performance issues?

Example:

How do I make tapping on the top text change the slider to full/empty?

Dragging the slider correctly communicates upwards when the slider changes from full to empty, but tapping the [Overview] full: text doesn't communicate downwards that the slider should change to full/empty.

I could store the underlying Double in the parent view, but it causes major lag and seems unnecessary.

import SwiftUI

// Top level View. It doesn't know anything about specific slider percentages,
// it only knows if the slider got moved to full/empty
struct SliderOverviewView: View {

    // Try setting this to true and rerunning.. It DOES work here?!
    @State var overview = OverviewModel(state: .empty)

    var body: some View {
        VStack {
            Text("[Overview] full: \(overview.state.rawValue)")
                .onTapGesture { // BROKEN: should update child..
                    switch overview.state {
                    case .full, .between: overview.state = .empty
                    case .empty: overview.state = .full
                    }
                }
            SliderDetailView(overview: $overview)
        }
    }
}


// Bottom level View. It knows about specific slider percentages and only
// communicates upwards when percentage goes to 0% or 100%.
struct SliderDetailView: View {

    @State var details: DetailModel

    init(overview: Binding<OverviewModel>) {
        details = DetailModel(overview: overview)
    }

    var body: some View {
        VStack {
            Text("[Detail] percentFull: \(details.percentFull)")

            Slider(value: $details.percentFull)
                .padding(.horizontal, 48)
        }
    }
}


// Top level model that only knows if slider went to 0% or 100%
struct OverviewModel {
    var state: OverviewState
    
    enum OverviewState: String {
        case empty
        case between
        case full
        
    }
}


// Lower level model that knows full slider percentage
struct DetailModel {
    @Binding var overview: OverviewModel
    var percentFull: Double {
        didSet {
            if percentFull == 0 {
                overview.state = .empty
            } else if percentFull == 1 {
                overview.state = .full
            } else {
                overview.state = .between
            }
        }
    }

    init(overview: Binding<OverviewModel>) {
        _overview = overview
        
        // set inital percent
        switch overview.state.wrappedValue {
        case .empty:
            percentFull = 0.0
        case .between:
            percentFull = 0.5
        case .full:
            percentFull = 1.0
        }
    }
}

struct SliderOverviewView_Previews: PreviewProvider {
    static var previews: some View {
        SliderOverviewView()
    }
}

Why don't I just store percentFull in the OverviewModel?

I'm looking for a pattern so my top level @State struct doesn't need to know EVERY low level detail specific to certain Views.

Running the code example is the clearest way to see my problem.

This question uses a contrived example where an Overview only knows if the slider is full or empty, but the Detail knows what percentFull the slider actually is. The Detail has very detailed control and knowledge of the slider, and only communicates upwards to the Overview when the slider is 0% or 100%

What's my specific case for why I need to do this?

For those curious, my app is running into performance issues because I have several gestures that give the user control over progress. I want my top level ViewModel to store if the gesture is complete or not, but it doesn't need to know the specifics of how far the user has swiped. I'm trying to hide this specific progress Double from my higher level ViewModel to improve app performance.



Solution 1:[1]

Here is working, simplified and refactored answer for your issue:

struct ContentView: View {
    var body: some View {
        SliderOverviewView()
    }
}

struct SliderOverviewView: View {
    @State private var overview: OverviewModel = OverviewModel(full: false) 
    var body: some View {
        VStack { 
            Text("[Overview] full: \(overview.full.description)")
                .onTapGesture {
                    overview.full.toggle()
                }
            SliderDetailView(overview: $overview)
        }
    }
}

struct SliderDetailView: View {
    @Binding var overview: OverviewModel
    var body: some View {
        VStack {  
            Text("[Detail] percentFull: \(tellValue(value: overview.full))") 
            Slider(value: Binding(get: { () -> Double in   
                return tellValue(value: overview.full) 
            }, set: { newValue in  
                if newValue == 1 { overview.full = true }
                else if newValue == 0 { overview.full = false } 
            }))
        }
    }
    
    func tellValue(value: Bool) -> Double {
        if value { return 1 }
        else { return 0 }
    }
}

struct OverviewModel {
    var full: Bool
}

Update:

struct SliderDetailView: View {
    @Binding var overview: OverviewModel
    @State private var sliderValue: Double = Double()
    var body: some View {
        VStack {   
            Text("[Detail] percentFull: \(sliderValue)")
            Slider(value: $sliderValue, in: 0.0...1.0)
        }
        .onAppear(perform: { sliderValue = tellValue(value: overview.full) })
        .onChange(of: overview.full, perform: { newValue in
            sliderValue = tellValue(value: newValue)
        })
        .onChange(of: sliderValue, perform: { newValue in
            if newValue == 1 { overview.full = true }
            else { overview.full = false }
        })
    }
    func tellValue(value: Bool) -> Double {
        value ? 1 : 0
    }
}

Solution 2:[2]

I present here a clean alternative using 2 ObservableObject, a hight level OverviewModel that only deal with if slider went to 0% or 100%, and a DetailModel that deals only with the slider percentage.

Dragging the slider correctly communicates upwards when the slider changes from full to empty, and tapping the [Overview] full: text communicates downwards that the slider should change to full/empty.

import Foundation
import SwiftUI

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}     

struct ContentView: View {
    @StateObject var overview = OverviewModel()
    
    var body: some View {
        SliderOverviewView().environmentObject(overview)
    }
}

// Top level View. It doesn't know anything about specific slider percentages,
// it only cares if the slider got moved to full/empty
struct SliderOverviewView: View {
    @EnvironmentObject var overview: OverviewModel
    
    var body: some View {
        VStack {
            Text("[Overview] full: \(overview.state.rawValue)")
                .onTapGesture {
                    switch overview.state {
                    case .full, .between: overview.state = .empty
                    case .empty: overview.state = .full
                    }
                }
            SliderDetailView()
        }
    }
}

// Bottom level View. It knows about specific slider percentages and only
// communicates upwards when percentage goes to 0% or 100%.
struct SliderDetailView: View {
    @EnvironmentObject var overview: OverviewModel
    @StateObject var details = DetailModel()
    
    var body: some View {
        VStack {
            Text("[Detail] percentFull: \(details.percentFull)")
            Slider(value: $details.percentFull).padding(.horizontal, 48)
                .onChange(of: details.percentFull) { newVal in
                    switch newVal {
                    case 0: overview.state = .empty
                    case 1: overview.state = .full
                    default: break
                    }
                }
        }
        // listen for the high level OverviewModel changes
        .onReceive(overview.$state) { theState in
            details.percentFull = theState == .full ? 1.0 : 0.0
        }
    }
}

enum OverviewState: String {
    case empty
    case between
    case full
}

// Top level model that only knows if slider went to 0% or 100%
class OverviewModel: ObservableObject {
    @Published var state: OverviewState = .empty
}

// Lower level model that knows full slider percentage
class DetailModel: ObservableObject {
    @Published var percentFull = 0.0
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Leo Dabus
Solution 2