0

I struggle with learning MVVM architecture. My problem is that I can't send data to the next VC.

App idea: I have 2 view controllers. In first VC user sets own parameteres (height and weight with UISlider). Later app presents second VC where user has got information about BMI.

MODEL

struct BMI {
        let value: Float
        let advice: String
        let color: UIColor
        let diagnosis: String 
    }

VIEW MODEL

protocol BmiViewControllerDelegate: class {
    func getCalculatedBMI(newBmi: BMI)
}

protocol BmiViewModelDelegate: class {
    func sendValue(height: Float?, weight: Float?)
}

class BmiViewModel: BmiViewModelDelegate {
 
    var bmiModel = BmiCalculator()
    
    var bmi: BMI
    
    weak var delegateVC: BmiViewControllerDelegate?
    
    func sendValue(height: Float?, weight: Float?) {
        
        guard let height = height, let weight = weight else { return }
        calculateBmi(height: height, weight: weight)

    
    }
    
    func calculateBmi(height: Float, weight: Float) {
        
        let bmiValue = weight / pow(height, 2)
    
        if bmiValue < 18.5 {
            bmi = BMI(value: bmiValue, advice: "You should eat more calories", color: .red, diagnosis: "Underweight")
            delegateVC?.getCalculatedBMI(newBmi: bmi!)
        } else if bmiValue < 24.9 {
            bmi = BMI(value: bmiValue, advice: "Your weight is great! Keep it up!", color: .green, diagnosis: "")
            delegateVC?.getCalculatedBMI(newBmi: bmi!)
        } else {
            bmi = BMI(value: bmiValue, advice: "You should eat less calories", color: .red, diagnosis: "Overweight")
            delegateVC?.getCalculatedBMI(newBmi: bmi!)
        }
    }
}
    

VIEW CONTROLLER

class BMIViewController: UIViewController {
    
    var bmiViewModel = BmiViewModel()

    @IBOutlet weak var heightLabel: UILabel!
    @IBOutlet weak var heightSlider: UISlider!
    
    @IBOutlet weak var weightLabel: UILabel!
    @IBOutlet weak var weightSlider: UISlider!
  
    @IBOutlet weak var calculateButton: UIButton!
    
 
    override func viewDidLoad() {
        super.viewDidLoad()
        
        bmiViewModel.delegateVC = self
        heightSlider.value = 1.5
        weightSlider.value = 80
    }
    
    @IBAction func heightSliderChanged(_ sender: UISlider) {
        
        let height = String(format: "%.2f", sender.value)
        heightLabel.text = "\(height)m"
    }
    
    @IBAction func weightSliderChanged(_ sender: UISlider) {
        
        let weight = String(format: "%.0f", sender.value)
        weightLabel.text = "\(weight)kg"
    }
    
    @IBAction func calculateButtonTapped(_ sender: UIButton) {
        let height = heightSlider.value
        let weight = weightSlider.value
        bmiViewModel.sendValue(height: height, weight: weight)
    }
}

extension BMIViewController: BmiViewControllerDelegate {
    func getCalculatedBMI(newBmi: BMI) {
        let bmiResult = BMIResultViewController()
        bmiResult.bmiValue = String(newBmi.value)
        bmiResult.advice = newBmi.advice
        bmiResult.diagnosis = newBmi.diagnosis
        bmiResult.color = newBmi.color
    }

I've tried to print values in getCalculatedBMI and these values exists, so why when I open BMIResultViewController values are empty.

And I have one additional question: if force unwrapped bmi values in "delegateVC?.getCalculatedBMI(newBmi: bmi!)" isn't bad approach?

kacper99
  • 157
  • 1
  • 7
  • "so why when I open BMIResultViewController values are empty.": Where do you open your controller? I just can see, that you initialize the vc. In 98-99 percent of cases, a force cast (!) is bad style. – kuzdu Jul 16 '21 at 18:28
  • I have a segue from calculateButton to BmiResultViewController in storyboard – kacper99 Jul 16 '21 at 18:29
  • Show me the segue please. You have to override `prepareForSegue` method. Have a look here: https://stackoverflow.com/a/26208831/4420355 – kuzdu Jul 16 '21 at 18:30
  • I think what you are doing is to init the `BMIResultViewController` Then you call your segue. But your segue will create his own `BMIResultViewController`. And therefore no data is transferred – kuzdu Jul 16 '21 at 18:32
  • I don't know if showing that has sense :D, because I've just dragged from button to another view. And when I do it with prepareForSegue it works, but I wonder if in MVVM I can set properties for next VC from current VC in prepare for segue (I wonder if its VM role) – kacper99 Jul 16 '21 at 18:34
  • If I understand you right, it works if you do it manually by code right? – kuzdu Jul 16 '21 at 19:23

1 Answers1

1

This code

func getCalculatedBMI(newBmi: BMI) {
    let bmiResult = BMIResultViewController()       
    bmiResult.bmiValue = String(newBmi.value)
    bmiResult.advice = newBmi.advice
    bmiResult.diagnosis = newBmi.diagnosis
    bmiResult.color = newBmi.color
}

Doesn't do what you want it to.

First, it allocates a new BMIResultViewController, but this view controller isn't shown in any way. It will just be thrown away when this function exits.

But this is academic because getCalculatedBMI isn't even called. You have used a delegation pattern, but the instance of BMIResultViewController that is shown by the segue can't invoke it since it doesn't have a model instance with the delegateVC property set.

First, let's fix your model and view model objects


class BMIModel {
    var weight: Float = 0 {
        didSet: {
            calculateBMI()
        }
    }
    var height: Float = 0 {
        didSet: {
            calculateBMI()
        }
    }

    var bmi: BMI

    init() {
        self.bmi = BMI(value: 0, advice: "You should eat more calories", color: .red, diagnosis: "Underweight")
    }

    private func calculateBMI() {
        let bmiValue = self.weight / pow(self.height, 2)
        if bmiValue < 18.5 {
            self.bmi = BMI(value: bmiValue, advice: "You should eat more calories", color: .red, diagnosis: "Underweight")
        } else if bmiValue < 24.9 {
            self.bmi = BMI(value: bmiValue, advice: "Your weight is great! Keep it up!", color: .green, diagnosis: "")
        } else {
            self.bmi = BMI(value: bmiValue, advice: "You should eat less calories", color: .red, diagnosis: "Overweight")
        }
    }
}

struct BmiViewModel {
    let bmiModel: BMIModel

    func setValues(weight: Float, height: Float) {
        self.bmiModel.weight = weight
        self.bmiModel.heihght = height
    }
}

struct BmiResultViewModel {
    let bmiModel: BMIModel

    var weight: Float {
        return bmiModel.weight
    }

    var height: Float {
        return bmiModel.height
    }
    
    var bmiResult: BMI {
        return bmiModel.bmi
    }
}

You have a final problem in that you have a segue triggered from your button and you are also using an @IBAction from that button to calculate the bmi. It isn't defined in which order these things will happen. In many cases the segue will trigger before the tap handler executes.

You can fix this by changing the storyboard segue to one linked to the view controller that you perform by identifier or you can perform the calculation during the segue.

The former is probably the correct approach:

  • Remove the segue between the button and the destination
  • Create a segue between the view controller object itself and the destination
  • Give the segue an identifier, say showResults
  • Fix your view model so that it updates the model.

Now you can invoke the segue programmatically. You need to implement prepare(for:sender) in BMIViewController in order to set properties on the destination view controller.

I wouldn't bother with the delegate pattern. Just set the destination view model.

class BMIViewController: UIViewController {

    let bmiViewModel = BmiViewModel(model: BMIModel())

    @IBAction func calculateButtonTapped(_ sender: UIButton) {
        let height = heightSlider.value
        let weight = weightSlider.value
        self.bmiViewModel.setValues(weight: weight, height: height)
        self.perform(segueWithIdentifier:"showResult", sender:self}
    }

    func prepare(for segue:UIStoryboardSegue, sender: any?) {
        if let destVC = segue.destinationViewController as? BMIResultViewController {
            destVC.bmiViewModel = BmiResultViewModel(bmiModel: self.bmiViewModel.bmiModel) 
        }
    }
}

This is just my opinion, but UIKit is inherently MVC. Retrofitting MVVM really only makes sense if you introduce a binding framework (which is what you were trying to do with the delegate pattern).

If you want to learn MVVM, use SwiftUI.

Paulw11
  • 108,386
  • 14
  • 159
  • 186