Using Combine + SwiftUI to update values when form values are updated - swift

I am learning SwiftUI and Combine to make a simple rent-splitting app. I am trying to follow the MVVM pattern and therefore have a Model, ViewModel and View as follows:
Model:
import Foundation
import Combine
struct Amounts {
var myMonthlyIncome : String = ""
var housemateMonthlyIncome : String = ""
var totalRent : String = ""
}
ViewModel:
import Foundation
import Combine
class FairRentViewModel : ObservableObject {
var amount: Amounts
init(_ amount: Amounts){
self.amount = amount
}
var myMonthlyIncome : String { return amount.myMonthlyIncome }
var housemateMonthlyIncome : String { return amount.housemateMonthlyIncome }
var totalRent : String { return amount.totalRent }
var yourShare: Double {
guard let totalRent = Double(totalRent) else { return 0 }
guard let myMonthlyIncome = Double(myMonthlyIncome) else { return 0 }
guard let housemateMonthlyIncome = Double(housemateMonthlyIncome) else { return 0 }
let totalIncome = Double(myMonthlyIncome + housemateMonthlyIncome)
let percentage = myMonthlyIncome / totalIncome
let value = Double(totalRent * percentage)
return Double(round(100*value)/100)
}
}
View:
import SwiftUI
import Combine
struct FairRentView: View {
#ObservedObject private var viewModel: FairRentViewModel
init(viewModel: FairRentViewModel){
self.viewModel = viewModel
}
var body: some View {
NavigationView {
Form {
Section(header: Text("Enter the total monthly rent:")) {
TextField("Total rent", text: $viewModel.amount.totalRent)
.keyboardType(.decimalPad)
}
Section(header: Text("Enter your monthly income:")) {
TextField("Your monthly wage", text: $viewModel.amount.myMonthlyIncome)
.keyboardType(.decimalPad)
}
Section(header: Text("Enter your housemate's monhtly income:")) {
TextField("Housemate's monthly income", text: $viewModel.amount.housemateMonthlyIncome)
.keyboardType(.decimalPad)
}
Section {
Text("Your share: £\(viewModel.yourShare, specifier: "%.2f")")
}
}
.navigationBarTitle("FairRent")
}
}
}
struct FairRentView_Previews: PreviewProvider {
static var previews: some View {
FairRentView(viewModel: FairRentViewModel(Amounts()))
}
}
The entry point:
#main
struct FairRentCalculatorApp: App {
var body: some Scene {
WindowGroup {
FairRentView(viewModel: FairRentViewModel(Amounts(myMonthlyIncome: "", housemateMonthlyIncome: "", totalRent: "")))
}
}
}
I want the yourShare value to update as the other properties are entered by the user in the form. This is what I have been trying to achieve with the above code. Can anyone please help point me in the right direction? I'm very new to SwiftUI + Combine and am trying my best to code cleanly so any other pointers are also welcome.
Thanks

You need something to signal to SwiftUI that a view needs to be updated.
ObservableObject objects have two ways to do that. One is directly via self.objectWillChange publisher, and the other - more common - is through its #Published properties that, when changed, use the objectWillChange automatically.
So, in your case, all you need to is mark amount property as #Published. Because it's a struct - a value-type - any change to its properties also changes the whole object:
#Published var amount: Amounts
Because the computed property yourShare is only ever updated when amount is updated, this would just work. The view would recompute itself with the now-updated yourShare.

Related

SwiftUI MVVM simple concept confusion - advice needed

I'm struggling with some basic MVVM concepts in SwiftUI. I appreciate this is probably a simple question but my brain is frazzled I can't figure it out.
Here's my models/views/viewmodels etc.
import Foundation
struct Challenges {
var all: [Challenge]
init() {
all = []
}
}
struct Challenge: Identifiable, Codable, Hashable {
private(set) var id = UUID()
private(set) var name: String
private(set) var description: String
private(set) var gpxFile: String
private(set) var travelledDistanceMetres: Double = 0
init(name: String, description: String, gpxFile: String) {
self.name = name
self.description = description
self.gpxFile = gpxFile
}
mutating func addDistance(_ distance: Double) {
travelledDistanceMetres += distance
}
}
import SwiftUI
#main
struct ActivityChallengesApp: App {
var body: some Scene {
WindowGroup {
ChallengesView()
.environmentObject(ChallengesViewModel())
}
}
}
import SwiftUI
class ChallengesViewModel: ObservableObject {
#Published var challenges: Challenges
init() {
challenges = Challenges()
challenges.all = DefaultChallenges.ALL
}
func addDistance(_ distance: Double, to challenge: Challenge) {
challenges.all[challenge].addDistance(distance)
}
}
import SwiftUI
struct ChallengesView: View {
#EnvironmentObject var challengesViewModel: ChallengesViewModel
var body: some View {
NavigationView {
List {
ForEach(challengesViewModel.challenges.all) { challenge in
NavigationLink {
ChallengeView(challenge)
.environmentObject(challengesViewModel)
} label: {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
}
}
}
.navigationTitle("Challenges")
}
}
}
import SwiftUI
struct ChallengeView: View {
var challenge: Challenge
#EnvironmentObject var challengesViewModel: ChallengesViewModel
init(_ challenge: Challenge) {
self.challenge = challenge
}
var body: some View {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
.onTapGesture {
handleTap()
}
}
func handleTap() {
challengesViewModel.addDistance(40, to: challenge)
}
}
I understand the concepts but I'm confused as to what the ViewModel should be.
I feel like this is overkill, i.e. sending a model object to the view and the view model as an environment object. With this set up, I call the addDistance() function in the view model from within the view to make changes to the model.
ChallengeView(challenge)
.environmentObject(challengesViewModel)
Is it better to have a view model for the collection or one view model per model object?
This is the simplest version I could come up with.
I don't really understand the need for the challenges.all ? So I took it out.
I have
a struct for the single challenge
an observable class which is publishing the challenges array
instantiate this once with #StateObject and pass it down as you did
btw: You don't need explicit initializers for structs
this is it:
#main
struct ActivityChallengesApp: App {
// here you create your model once
#StateObject var challenges = ChallengesModel()
var body: some Scene {
WindowGroup {
ChallengesView()
.environmentObject(challenges)
}
}
}
struct Challenge: Identifiable, Codable, Hashable {
var id = UUID()
var name: String
var description: String
var gpxFile: String
var travelledDistanceMetres: Double = 0
mutating func addDistance(_ distance: Double) {
travelledDistanceMetres += distance
}
}
class ChallengesModel: ObservableObject {
#Published var challenges: [Challenge]
init() {
// Test data
challenges = [
Challenge(name: "Challenge One", description: "?", gpxFile: ""),
Challenge(name: "Challenge Two", description: "?", gpxFile: ""),
Challenge(name: "Last Challenge", description: "?", gpxFile: "")
]
}
func addDistance(_ distance: Double, to challenge: Challenge) {
// find the challenge and update it
if let i = challenges.firstIndex(where: {$0.id == challenge.id}) {
challenges[i].addDistance(distance)
}
}
}
struct ChallengesView: View {
#EnvironmentObject var challengesModel: ChallengesModel
var body: some View {
NavigationView {
List {
ForEach(challengesModel.challenges) { challenge in
NavigationLink {
ChallengeView(challenge: challenge)
.environmentObject(challengesModel)
} label: {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
}
}
}
.navigationTitle("Challenges")
}
}
}
struct ChallengeView: View {
var challenge: Challenge
#EnvironmentObject var challengesModel: ChallengesModel
var body: some View {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
.onTapGesture {
handleTap()
}
}
func handleTap() {
challengesModel.addDistance(40, to: challenge)
}
}

Unable to store selected Picker value in SwiftUI

I have a simple SwiftUI view with a Picker containing a list of objects from a data array. The Picker lists the objects just fine, but the selected value is not being saved to the binding variable $selectedCar. It returns empty string. This is the view in question:
struct GarageSpace: View {
var currentUserID: String
#Environment(\.presentationMode) var presentationMode
#Binding var selectedPlaceID: String
#Binding var selectedPlaceName: String
#Binding var selectedPlaceDate: Date
#Binding var selectedSpaceID: String
#State var selectedCar: String
#Binding var cars: CarArrayObject
var body: some View {
VStack{
Group{
Picker("Car", selection: $selectedCar) {
if let cars = cars{
ForEach(cars.dataArray, id: \.self) {car in
let year = car.year! as String
let make = car.make as String
let model = car.model! as String
let string = year + " " + make + " " + model
Text(string) //displays correctly in Picker
}
}
}
Spacer()
if let cars = cars {
Button {
print("yes")
print(selectedCar) //returns empty string
} label: {
Text("Confirm")
}
}
}
}
}
}
The above view is displayed via a NavigationLink on the previous screen:
NavigationLink(destination: GarageSpace(currentUserID: currentUserID, selectedPlaceID: $selectedPlaceID, selectedPlaceName: $selectedPlaceName, selectedPlaceDate: $selectedPlaceDate, selectedSpaceID: $selectedSpaceID, selectedCar: "", cars: $cars)) {
}
This NavigationLink might be the culprit because I'm sending an empty string for selectedCar. However, it forces me to initialize a value with the NavigationLink.
Any ideas? Thanks!
EDIT:
Added a tag of type String, still same outcome:
Text(string).tag(car.carID)
EDIT: FOUND THE ISSUE! However, I'm still stumped. The selection variable is empty because I wasn't pressing on the Picker since I only had one item in the array. How can I get the Picker to "select" an item if it's the only one in the array by default?
With tag, all works well in my simple tests. Here is my test code:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
GarageSpace()
}
}
struct GarageSpace: View {
#State var selectedCar: String = ""
#State var cars: CarArrayObject? = CarArrayObject(car: CarModel(make: "Ford"))
var body: some View {
VStack {
Group {
Picker("Car", selection: $selectedCar) {
if let cars = cars {
ForEach(cars.dataArray, id: \.self) { car in
Text(car.make).tag(car.carID)
}
}
}
Spacer()
if let cars = cars {
Button {
print("----> selectedCar carID: \(selectedCar)")
} label: {
Text("Show selected carID")
}
}
}
}
// optional, to select the first car
.onAppear {
if let cars = cars {
selectedCar = (cars.dataArray.first != nil) ? cars.dataArray.first!.carID : ""
}
}
}
}
struct CarModel: Hashable {
var make = ""
var carID = UUID().uuidString
}
class CarArrayObject: ObservableObject{
// for testing
#Published var dataArray = [CarModel(make: "Toyota"), CarModel(make: "Suzuki"), CarModel(make: "VW")]
/// USED FOR SINGLE CAR SELECTION
init(car: CarModel) {
self.dataArray.append(car)
}
/// USED FOR GETTING CARS FOR USER PROFILE
init(userID: String) {
// print("GET CARS FOR USER ID \(userID)")
// DataService.instance.downloadCarForUser(userID: userID) { (returnedCars) in
//
// let sortedCars = returnedCars.sorted { (car1, car2) -> Bool in
// return car1.dateCreated > car2.dateCreated
// }
// self.dataArray.append(contentsOf: sortedCars)
// }
}
}

Storing a selected value from picker to use in other views - SwiftUI

I was very kindly helped to allow my picker to select a value from my Firestore database. What I would like to do is once that value is selected in my picker I would like to be able to show that value in other views. I have tried setting this up using UserDefaults but I'm not sure that's the way to go? If you could suggest a better method I'd be more than grateful. My code currently is below.
The value in the below code returns Unknown School each time but without the user defaults works flawlessly in fetching the data.
Thanks in advance.
import SwiftUI
import Firebase
struct SchoolDetailsView: View {
#ObservedObject var schoolData = getSchoolData()
#State private var selectedSchool = UserDefaults.standard.string(forKey: "") // `schoolName.id` is of type String
var body: some View {
VStack {
Form {
Section {
Picker(selection: $selectedSchool, label: Text("School Name")) {
ForEach(schoolData.datas, id: \.id) {
Text($0.name)
}
}
Text("Selected School: \(selectedSchool ?? "Unknown School")")
}
}.navigationBarTitle("School Selection")
}
}
struct SchoolPicker_Previews: PreviewProvider {
static var previews: some View {
SchoolDetailsView()
}
}
class getSchoolData : ObservableObject{
#Published var datas = [schoolName]()
init() {
let db = Firestore.firestore()
db.collection("School Name").addSnapshotListener { (snap, err) in
if err != nil{
print((err?.localizedDescription)!)
return
}
for i in snap!.documentChanges{
let id = i.document.documentID
let name = i.document.get("Name") as! String
self.datas.append(schoolName(id: id, name: name))
}
}
}
}
struct schoolName : Identifiable {
var id : String
var name : String
}
}
First, the UserDefaults key for your variable can't be empty:
#State private var selectedSchool: String = UserDefaults.standard.string(forKey: "selectedSchool") ?? "Unknown School"
Then you can use onReceive to update the variable:
.onReceive(Just(selectedSchool)) {
UserDefaults.standard.set($0, forKey: "selectedSchool")
}
Full code:
import Combine
import Firebase
import SwiftUI
struct SchoolDetailsView: View {
#ObservedObject var schoolData = getSchoolData()
#State private var selectedSchool = UserDefaults.standard.string(forKey: "selectedSchool")
var body: some View {
VStack {
Form {
Section {
Picker(selection: $selectedSchool, label: Text("School Name")) {
ForEach(schoolData.datas, id: \.id) {
Text($0.name)
}
}
.onReceive(Just(selectedSchool)) {
UserDefaults.standard.set($0, forKey: "selectedSchool")
}
Text("Selected School: \(selectedSchool)")
}
}.navigationBarTitle("School Selection")
}
}
}
Note that in SwiftUI 2 / iOS 14 you can use #AppStorage instead.

Initializer 'init(_:)' requires that '' conform to 'StringProtocol' SwiftUI Picker with Firebase

I have a piece of code where I'm trying to place Firestore data within a picker. I have made it previously so that the picker will show the Firestore data, but I am unable to select it to show in the 'selected view' therefore I rewrote the code and have the following error "Initializer 'init(_:)' requires that 'getSchoolData' conform to 'StringProtocol' "
Please excuse that it may sound like a daft question I just can't seem to solve it. A copy of my code is below. I have tried working on this for weeks but at a loss so please be kind, I'm new to coding.
Thanks in advance,
import SwiftUI
import Firebase
struct SchoolDetailsView: View {
let schoolData = [getSchoolData()]
#State var selectedSchool = 0
var body: some View {
VStack {
Form {
Section {
Picker(selection: $selectedSchool, label: Text("School Name")) {
ForEach(0 ..< schoolData.count) {
Text(self.schoolData[$0])
}
}
Text("Selected School: \(selectedSchool)")
}
}.navigationBarTitle("Select your school")
}
}
}
struct SchoolPicker_Previews: PreviewProvider {
static var previews: some View {
SchoolDetailsView()
}
}
class getSchoolData : ObservableObject{
#Published var datas = [schoolName]()
init() {
let db = Firestore.firestore()
db.collection("School Name").addSnapshotListener { (snap, err) in
if err != nil{
print((err?.localizedDescription)!)
return
}
for i in snap!.documentChanges{
let id = i.document.documentID
let name = i.document.get("Name") as! String
self.datas.append(schoolName(id: id, name: name))
}
}
}
}
struct schoolName : Identifiable {
var id : String
var name : String
}
You may try the following:
struct SchoolDetailsView: View {
#ObservedObject var schoolData = getSchoolData() // make `#ObservedObject`/`#StateObject` instead of const array
#State var selectedSchool = "" // `schoolName.id` is of type String
var body: some View {
VStack {
Form {
Section {
Picker(selection: $selectedSchool, label: Text("School Name")) {
ForEach(schoolData.datas, id: \.id) { // choose whether you want to tag by `id` or by `name`
Text($0.name)
}
}
Text("Selected School: \(selectedSchool)")
}
}.navigationBarTitle("Select your school")
}
}
}

ObservableObject has a different instantiation in my class

I have an ObservableObject that stores the current country the user is wanting information on and I want this to be shared across all views and classes. I properly declared it in the scene delegate so there is no issue with that.
import Foundation
class GlobalData: ObservableObject {
#Published var whichCountry: String = "italy"
}
This is my main view of where I call an environment object to get whichCountry. When the users click the button it will open ListOfCountriesView() and pass that EnvironemtnObject through it to update the new country the users want.
import SwiftUI
struct InDepthView: View {
#State var showList = false
#EnvironmentObject var globalData: GlobalData
#ObservedObject var data = getDepthData(globalData: GlobalData())
var body: some View {
VStack(alignment: .leading) {
Button(action: { self.showList.toggle() }) {
VStack(alignment: .leading) {
HStack {
Text("\(self.data.globalDatam.whichCountry.uppercased())")
}
}
}
.sheet(isPresented: $showList) {
ListOfCountriesView().environmentObject(GlobalData())
}
}
}
}
import SwiftUI
struct ListOfCountriesView: View {
#EnvironmentObject var globalData: GlobalData
var body: some View {
ScrollView {
VStack(spacing: 15) {
Text("List of Countries")
.padding(.top, 25)
Button(action: {
self.globalData.whichCountry = "usa"
self.presentationMode.wrappedValue.dismiss()
}) {
VStack {
Text("\(self.globalData.whichCountry)")
.font(.system(size: 25))
Divider()
}
}
}
}
}
}
struct ListOfCountriesView_Previews: PreviewProvider {
static var previews: some View {
ListOfCountriesView().environmentObject(GlobalData())
}
}
When the user changes the country I want this class which is inside my InDepthView.swift file to be updated with the new string. But for some reason, it is still displaying "italy" when it should have changed to "usa" based on what happened in ListOfCountriesView(). So I know that there is two instantiations of GlobalData but I'm not sure how to fix this issue. Any help would be greatly appreciated as I have been spending the past two days trying to fix this issue!
class getDepthData: ObservableObject {
#Published var data : Specific!
#Published var countries : HistoricalSpecific!
var globalDatam: GlobalData
init(globalData: GlobalData) {
globalDatam = globalData
print(globalDatam.whichCountry + " init()")
updateData()
}
func updateData() {
let url = "https://corona.lmao.ninja/v2/countries/" // specific country
let session = URLSession(configuration: .default
session.dataTask(with: URL(string: url+"\(self.globalDatam.whichCountry)")!) { (data, _, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
let json = try! JSONDecoder().decode(Specific.self, from: data!)
DispatchQueue.main.async {
self.data = json
}
}.resume()
}
}
///////
I added this to the code like you mentioned. but recieving an error
import SwiftUI
struct InDepthView: View {
#State var showList = false
#State var pickerSelectedItem = 1
#EnvironmentObject var globalData: GlobalData
#ObservedObject var data: getDepthData
init() {
self.data = getDepthData(globalData: self.globalData)
}
ERRROR : self' used before all stored properties are initialized
You're creating a second GlobalData instance when you call
#ObservedObject var data = getDepthData(globalData: GlobalData())
Edit: Removed example that was passing the environment object in as an argument. That doesn't work and it crashes.
You will need to refactor a bit to either structure your app a bit differently altogether, or you could remove the environment object, and instead initialise GlobalData() in your first view and then just pass it into each subsequent view as an #ObservedObject, rather than as #EnvironmentObject via scene delegate.
The following is pseudocode but I hope clarifies what I mean:
struct ContentView: View {
#ObservedObject var globalData = GlobalData()
var body: some View {
...
NavigationLink("Linky link", destination: SecondView(globalData: globalData, data: getDepthData(globalData: globalData))
}
}
struct SecondView: View {
#ObservedObject var globalData: GlobalData
#ObservedObject var data: getDepthData
init(globalData: GlobalData, data: getDepthData) {
self.globalData = globalData
self.data = getDepthData
}
...
}