2

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.

joshuakcockrell
  • 5,200
  • 2
  • 34
  • 47
  • You have no View Models to contain the logic. SwiftUI works with MVVM architecture. Model -View-ViewModel. In the ViewModel, you would have at least one `@Published~ properties that would cause the views to update like you are expecting. – Yrb Jan 31 '22 at 01:38
  • I don't have a solution to offer in the direction that you're pursuing, but I'll say that while I definitely sympathize with some of the performance issues that *can* come along with transmitting state down the view hierarchy in SwiftUI, I think that you'll probably get more long-term out of figuring out how to write code that updates as few views as possible and keeps as much of the hierarchy intact. If you do that, it can become trivial to transmit changing slider values like this, even with hundreds or thousands of elements. Consider checking out TCA, for example. – jnpdx Jan 31 '22 at 02:04
  • @Yrb This is a simplified example to make it easier to understand. The actual code this example is taken from DOES use MVVM, and that definitely doesn't fix anything. I modified my example here so you can see that a true `@ObservedObject ViewModel` with `@Published` properties doesn't fix the problem: https://pastebin.com/HTUXy2FY – joshuakcockrell Jan 31 '22 at 02:16
  • Put `@Binding var overview: OverviewModel` into `SliderDetailView`, otherwise you have no dependency on source of truth and so no update. Actually `@Binding` is not part of model, by concept, so should not be there. – Asperi Jan 31 '22 at 06:53

2 Answers2

0

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
    }
}
Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
ios coder
  • 1
  • 4
  • 31
  • 91
  • Hmm. Looks like this broke the Slider drag functionality. I need both the `full: Bool` and the `percentFull: Double`. – joshuakcockrell Jan 31 '22 at 01:42
  • No, it does not broke, it works fine, I can slide it, slider can slide just for 1.0 or 0.0, there is no other way around to define a Bool value in between, if we say 0.0 is false and 1.0 true, then what is 0.5? there is no Bool type for 0.5. The limitation is not because Slider, it is because of Bool. – ios coder Jan 31 '22 at 01:43
  • @joshuakcockrell: I made an update to my answer to cover what you want as well. – ios coder Jan 31 '22 at 01:55
  • There are plenty of real apps where you would need to track a slider's range, but the top level model only needs to know if the slider completed or not. Think about swiping left/right on Tinder, or submitting a trade on Robinhood. I understand the `Bool` state is confusing, so updated my example code and example video to account for an inbetween state, but that's not really what the question's about. It's about communication back and forth between a child view, and a parent view that wants limited info about the child's state. – joshuakcockrell Jan 31 '22 at 02:00
  • @joshuakcockrell: My update is actually what you are looking in your question, about the gif. Have you tried the update? PS: I see that you changed/updated the question and gif as well from your original question. But Still my update answer your question and you can modify it in your custom way. – ios coder Jan 31 '22 at 02:10
0

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
}