let us imagine that I have something like the following core/model:
class Core: ObservableObject {
...
func action(confirm: () -> Bool) {
if state == .needsConfirmation, !confirm() {
return
}
changeState()
}
...
}
and then I use this core object in a SwiftUI view.
struct ListView: View {
...
var body: some View {
List(objects) {
Text($0)
.onTapGesture {
core.action {
// present an alert to the user and return if the user confirms or not
}
}
}
}
}
So boiling it down, I wonder how to work with handlers there need an input from the user, and I cant wrap my head around it.
It looks like you reversed interactivity concept, instead you need something like below (scratchy)
struct ListView: View {
#State private var confirmAlert = false
...
var body: some View {
List(objects) {
Text($0)
.onTapGesture {
if core.needsConfirmation {
self.confirmAlert = true
} else {
self.core.action() // << direct action
}
}
}
.alert(isPresented: $confirmAlert) {
Alert(title: Text("Title"), message: Text("Message"),
primaryButton: .default(Text("Confirm")) {
self.core.needsConfirmation = false
self.core.action() // <<< confirmed action
},
secondaryButton: .cancel())
}
}
}
class Core: ObservableObject {
var needsConfirmation = true
...
func action() {
// just act
}
...
}
Alternate: with hidden condition checking in Core
struct ListView: View {
#ObservedObject core: Core
...
var body: some View {
List(objects) {
Text($0)
.onTapGesture {
self.core.action() // << direct action
}
}
.alert(isPresented: $core.needsConfirmation) {
Alert(title: Text("Title"), message: Text("Message"),
primaryButton: .default(Text("Confirm")) {
self.core.action(state: .confirmed) // <<< confirmed action
},
secondaryButton: .cancel())
}
}
}
class Core: ObservableObject {
#Published var needsConfirmation = false
...
func action(state: State = .check) {
if state == .check && self.state != .confirmed {
self.needsConfirmation = true
return;
}
self.state = state
// just act
}
...
}
Related
I have a struct called Activity which has an id (UUID), name (String), description (String) and timesCompleted (Int).
I also have a class called Activities that contains an array of Activity structs called activityList. Activities is marked with ObservableObject.
I have activities declared as a #StateObject in my ContentView and I pass it to my ActivityDetailView where it is declared as an #ObservedObject.
However I can only partially write to activities.activityList in the child view. I can append, but I can't overwrite, update or remove an element from the array. No error is thrown but the view immediately crashes and the app returns to the main ContentView.
How do you update/write to an #ObservedObject? As you can see from the comments in my updateTimesCompleted() function I've tried all kinds of things to update/overwrite an existing element. All crash silently and return to ContentView. Append does not fail, but isn't the behavior I want, I want to update/overwrite an array element, not append a new copy.
Activity Struct:
struct Activity : Codable, Identifiable, Equatable {
var id = UUID()
var name: String
var description: String
var timesCompleted: Int
}
Activities Class:
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
ContentView:
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach(activities.activityList) { activity in
NavigationLink {
ActivityDetailView(activity: activity, activities: activities)
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
let _ = print("add activity")
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
}
}
ActivityDetailView:
struct ActivityDetailView: View {
#State private var timesCompleted = 0
let activity: Activity
#ObservedObject var activities: Activities
var body: some View {
NavigationView {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(timesCompleted)")
} onIncrement: {
timesCompleted += 1
updateTimesCompleted()
} onDecrement: {
if timesCompleted > 0 {
timesCompleted -= 1
updateTimesCompleted()
}
}
}
.navigationTitle("Activity Details")
}
}
func updateTimesCompleted() {
let newActivity = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
let _ = print("count: \(activities.activityList.count)")
let index = activities.activityList.firstIndex(of: activity)
let _ = print(index ?? -666)
if let index = index {
activities.activityList[index] = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
//activities.activityList.swapAt(index, activities.activityList.count - 1)
//activities.activityList[index].incrementTimesCompleted()
//activities.activityList.append(newActivity)
//activities.activityList.remove(at: index)
//activities.activityList.removeAll()
//activities.activityList.append(newActivity)
}
}
}
You could try this approach, where the activity is passed to the ActivityDetailView
as a binding.
In addition, #ObservedObject var activities: Activities is used directly in AddActivityView to add an Activity to the list.
struct Activity : Codable, Identifiable, Equatable {
let id = UUID() // <-- here
var name: String
var description: String
var timesCompleted: Int
enum CodingKeys: String, CodingKey { // <-- here
case name,description,timesCompleted
}
}
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach($activities.activityList) { $activity in // <-- here
NavigationLink {
ActivityDetailView(activity: $activity) // <-- here
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
.onAppear {
// for testing
if activities.activityList.isEmpty {
activities.activityList.append(Activity(name: "activity-1", description: "activity-1", timesCompleted: 1))
activities.activityList.append(Activity(name: "activity-2", description: "activity-2", timesCompleted: 2))
activities.activityList.append(Activity(name: "activity-3", description: "activity-3", timesCompleted: 3))
}
}
}
}
// -- here for testing
struct AddActivityView: View {
#ObservedObject var activities: Activities
var body: some View {
Text("AddActivityView")
Button("add activity") {
activities.activityList.append(Activity(name: "workingDog", description: "workingDog", timesCompleted: 5))
}
}
}
struct ActivityDetailView: View {
#Binding var activity: Activity // <-- here
var body: some View {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(activity.timesCompleted)")
} onIncrement: {
activity.timesCompleted += 1 // <-- here
} onDecrement: {
if activity.timesCompleted > 0 {
activity.timesCompleted -= 1 // <-- here
}
}
}
}
}
Really just starting out with SwiftUI and trying to get my head around MVVM.
The code below displays a height and 4 toggle buttons, thus far I have only connected 2.
Major up and Major Down.
When clicked I see in the console that the value is altered as expected.
What I don't see is the the main display updating to reflect the change.
I have tried refactoring my code to include the view model into each Struct but still not seeing the change.
I think I have covered the basics but am stumped, I'm using a single file for now but plan to move the Model and ViewModel into separate files when I have a working mockup.
Thanks for looking.
import SwiftUI
/// This is our "ViewModel"
class setHeightViewModel: ObservableObject {
struct ImperialAndMetric {
var feet = 16
var inches = 3
var meters = 4
var CM = 95
var isMetric = true
}
// The model should be Private?
// ToDo: Fix the private issue.
#Published var model = ImperialAndMetric()
// Our getters for the model
var feet: Int { return model.feet }
var inches: Int { return model.inches }
var meters: Int { return model.meters }
var cm: Int { return model.CM }
var isMetric: Bool { return model.isMetric }
/// Depending upon the selected mode, move the major unit up by one.
func majorUp() {
if isMetric == true {
model.meters += 1
print("Meters is now: \(meters)")
} else {
model.feet += 1
print("Feet is now: \(feet)")
}
}
/// Depending upon the selected mode, move the major unit down by one.
func majorDown() {
if isMetric == true {
model.meters -= 1
print("Meters is now: \(meters)")
} else {
model.feet -= 1
print("Feet is now: \(feet)")
}
}
/// Toggle the state of the display mode.
func toggleMode() {
model.isMetric = !isMetric
}
}
// This is our View
struct ViewSetHeight: View {
// UI will watch for changes for setHeihtVM now.
#ObservedObject var setHeightVM = setHeightViewModel()
var body: some View {
NavigationView {
Form {
ModeArea(viewModel: setHeightVM)
SelectionUp(viewModel: setHeightVM)
// Show the correct height format
if self.setHeightVM.isMetric == true {
ValueRowMetric(viewModel: self.setHeightVM)
} else {
ValueRowImperial(viewModel: self.setHeightVM)
}
SelectionDown(viewModel: setHeightVM)
}.navigationTitle("Set the height")
}
}
}
struct ModeArea: View {
var viewModel: setHeightViewModel
var body: some View {
Section {
if viewModel.isMetric == true {
SwitchImperial(viewModel: viewModel)
} else {
SwitchMetric(viewModel: viewModel)
}
}
}
}
struct SwitchImperial: View {
var viewModel: setHeightViewModel
var body: some View {
HStack {
Button(action: {
print("Imperial Tapped")
}, label: {
Text("Imperial").onTapGesture {
viewModel.toggleMode()
}
})
Spacer()
Text("\(viewModel.feet)\'-\(viewModel.inches)\"").foregroundColor(.gray)
}
}
}
struct SwitchMetric: View {
var viewModel: setHeightViewModel
var body: some View {
HStack {
Button(action: {
print("Metric Tapped")
}, label: {
Text("Metric").onTapGesture {
viewModel.toggleMode()
}
})
Spacer()
Text("\(viewModel.meters).\(viewModel.cm) m").foregroundColor(.gray)
}
}
}
struct SelectionUp: View {
var viewModel: setHeightViewModel
var body: some View {
Section {
HStack {
Button(action: {
print("Major Up Tapped")
viewModel.majorUp()
}, label: {
Image(systemName: "chevron.up").padding()
})
Spacer()
Button(action: {
print("Minor Up Tapped")
}, label: {
Image(systemName: "chevron.up").padding()
})
}
}
}
}
struct ValueRowImperial: View {
var viewModel: setHeightViewModel
var body: some View {
HStack {
Spacer()
Text(String(viewModel.feet)).accessibility(label: Text("Feet"))
Text("\'").foregroundColor(Color.gray).padding(.horizontal, -10.0).padding(.top, -15.0)
Text("-").foregroundColor(Color.gray).padding(.horizontal, -10.0)
Text(String(viewModel.inches)).accessibility(label: Text("Inches"))
Text("\"").foregroundColor(Color.gray).padding(.horizontal, -10.0).padding(.top, -15.0)
Spacer()
}.font(.largeTitle).padding(.zero)
}
}
struct ValueRowMetric: View {
var viewModel: setHeightViewModel
var body: some View {
HStack {
Spacer()
Text(String(viewModel.meters)).accessibility(label: Text("Meter"))
Text(".").padding(.horizontal, -5.0).padding(.top, -15.0)
Text(String(viewModel.cm)).accessibility(label: Text("CM"))
Text("m").padding(.horizontal, -5.0).padding(.top, -15.0).font(.body)
Spacer()
}.font(.largeTitle)
}
}
struct SelectionDown: View {
var viewModel: setHeightViewModel
var body: some View {
Section {
HStack {
Button(action: {
print("Major Down Tapped")
viewModel.majorDown()
}, label: {
Image(systemName: "chevron.down").padding()
})
Spacer()
Button(action: {
print("Minor Down Tapped")
}, label: {
Image(systemName: "chevron.down").padding()
})
}
}
}
}
At the moment you receive the setHeightViewModel in various views as a var,
you should receive it as an ObservedObject.
I suggest you try this, in ViewSetHeight
#StateObject var setHeightVM = setHeightViewModel()
and in all your other views where you pass this model, use:
#ObservedObject var viewModel: setHeightViewModel
such as in ModeArea, SwitchImperial, SwitchMetric, SelectionUp, etc...
Alternatively you could use #EnvironmentObject ... to pass the setHeightViewModel
to all views that needs it.
I have this class:
class MyModel:ObservableObject {
var selectedObjectIndex:Int {
set {}
get {
let selectedResult = objects.filter({ $0 == selectedObject })
if selectedResult == 0 { return 0 }
return objects.firstIndex(of: selectedResult.first!)!
}
}
}
This selectedObjectIndex would normally be a #Published var selectedObjectIndex:Int but because it is a computed property, I cannot use #Published there.
So I was forced to add this to the model
func getselectedObjectIndexBinding() -> Binding<Int> {
let binding = Binding<Int>(
get: { () -> Int in
return self.selectedObjectIndex
}) { (newValue) in
self.selectedObjectIndex = newValue
}
return binding
}
In the view I have a Picker, using this like
Picker(selection: myModel.getselectedObjectIndexBinding(),
label: Text("select")) {
The picker appears correctly but If I set a new value on it, the model selectedObjectIndex is not updated.
You can try something like below, whenever a binding is updated, your selectedObjectIndex can trigger ObjectWillChange notification after doing some relevant work. I have created selectedObjectIndex as propertyObserver.
struct CreditCardFront: View {
#ObservedObject var myModel:MyModel
var body: some View {
Picker(selection: myModel.getselectedObjectIndexBinding(),
label: Text("select")) {
ForEach(1..<10) { value in
Text("\(value)")
}
}
Text("\(myModel.selectedObjectIndex ?? 0)")
}
}
class MyModel:ObservableObject {
var selectedObjectIndex:Int?{
didSet{
// Your work here
update() // call notification on specific change
}
}
func getselectedObjectIndexBinding() -> Binding<Int> {
let binding = Binding<Int>(
get: { () -> Int in
return self.selectedObjectIndex ?? 0
}) { (newValue) in
self.selectedObjectIndex = newValue
}
return binding
}
func update(){
self.objectWillChange.send()
}
}
#main-:
#main
struct WaveViewApp: App {
#StateObject var model:MyModel
init() {
let model = MyModel()
_model = StateObject(wrappedValue: model)
}
var body: some Scene {
WindowGroup {
CreditCardFront(myModel: model)
}
}
}
I have the following view hierarchy
Nurse List View > Nurse Card > Favorite button
Nurse List View
struct NurseListView: View {
#State var data: [Nurse] = []
var body: some View {
List {
ForEach(data.indices, id: \.self) { index in
NurseCard(data: self.$data[index])
}
}
}
}
Nurse Card
struct NurseCard: View {
#Binding var data: Nurse
var body: some View {
FavoriteActionView(data:
Binding(
get: { self.data },
set: { self.data = $0 as! Nurse }
)
)
}
}
Favorite Action View
struct FavoriteActionView: View {
#Binding var data: FavoritableData
var body: some View {
Button(action: {
self.toggleFavIcon()
}) {
VStack {
Image(data.isFavorite ? "fav-icon" : "not-fav-icon")
Text(String(data.likes.count))
}
}
}
private func toggleFavIcon() {
if data.isFavorite {
if let index = data.likes.firstIndex(of: AppState.currentUser.uid) {
data.likes.remove(at: index)
}
} else {
data.likes.append(AppState.currentUser.uid)
}
}
}
When toggleFavIcon execute, it append/remove the user id from the likes property in data object but I can't see the change unless I go back to previous page and reopen the page. What I am missing here?
As Asperi wrote, using an ObservableObject would work well here. Something like this:
class FavoritableData: ObservableObject {
#Published var likes: [String] = []
#Published var isFavorite = false
}
struct FavoriteActionView: View {
#ObservedObject var data: FavoritableData
var body: some View {
Button(action: {
self.toggleFavIcon()
}) {
VStack {
Image(data.isFavorite ? "fav-icon" : "not-fav-icon")
Text(String(data.likes.count))
}
}
}
private func toggleFavIcon() {
if data.isFavorite {
if let index = data.likes.firstIndex(of: AppState.currentUser.uid) {
data.likes.remove(at: index)
}
} else {
data.likes.append(AppState.currentUser.uid)
}
}
}
In SwiftUI I've created a struct that should create different overlay views depending on some state variables. If any of the state booleans is true, then it should return custom view (either ErrorOverlay or LoadingOverlay or else an EmptyView) like this:
struct OverlayContainer: View {
#State var isLoading: Bool = false
#State var isErrorShown: Bool = false
func setIsLoading(isLoading: Bool) {
self.isLoading = isLoading
}
func setIsErrorShown(isErrorShown: Bool) {
self.isErrorShown = isErrorShown
}
var body: some View {
Group {
if(isErrorShown) {
ErrorOverlay()
}
else if(isLoading) {
LoadingOverlay()
}
else {
EmptyView()
}
}
}
}
Now I've implemented the overlay on some content in the Home view with buttons that should change the state and show the correct overlay, like this:
struct Home: View {
var body: some View {
let overlayContainer = OverlayContainer()
return HStack {
// Some more content here
Button(action: {
overlayContainer.setIsLoading(isLoading: true)
}) {
Text("Start loading")
}
Button(action: {
overlayContainer.setIsErrorShown(isErrorShown: true)
}) {
Text("Show error")
}
}.overlay(overlayContainer)
}
}
This isn't working: when I click the button nothing happens. Why and how to solve this? (without using binding, see below)
ps. I've been able to get a working solution by doing the following:
extracting the state booleans to the Home view
pass these through the constructor of the OverlayContainer
change the state booleans instead of calling the set methods when clicking the buttons
change the OverlayContainer so it implements an init method with both booleans
change the state booleans in the OverlayContainer to bindings.
However, I'd like to implement the states in the OverlayContainer to be able to re-use that in different screens, without implementing state variables in all of these screens. Firstly because there will probably be more cases than just these 2. Secondly because not all screens will need to access all states and I haven't found out a simple way to implement optional bindings through the init method.
To me it feels that all these states belong to the OverlayContainer, and changing the state should be as short and clean as possible. Defining states everywhere feels like code duplication. Maybe I need a completely different architecture?
It should be used Binding instead. Here is possible solution.
struct OverlayContainer: View {
#Binding var isLoading: Bool
#Binding var isErrorShown: Bool
var body: some View {
Group {
if(isErrorShown) {
ErrorOverlay()
}
else if(isLoading) {
LoadingOverlay()
}
else {
EmptyView()
}
}
}
}
struct Home: View {
#State var isLoading: Bool = false
#State var isErrorShown: Bool = false
var body: some View {
HStack {
// Some more content here
Button(action: {
self.isLoading = true
}) {
Text("Start loading")
}
Button(action: {
self.isErrorShown = true
}) {
Text("Show error")
}
}.overlay(OverlayContainer(isLoading: $isLoading, isErrorShown: $isErrorShown))
}
}
To make it the way you want, use Binding:
struct OverlayContainer: View {
#Binding var isLoading: Bool
#Binding var isErrorShown: Bool
func setIsLoading(isLoading: Bool) {
self.isLoading = isLoading
self.isErrorShown = !isLoading
}
func setIsErrorShown(isErrorShown: Bool) {
self.isErrorShown = isErrorShown
self.isLoading = !isErrorShown
}
var body: some View {
Group {
if(isErrorShown) {
ErrorOverlay()
}
else if(isLoading) {
LoadingOverlay()
}
else {
EmptyView()
}
}
}
}
struct Home: View {
#State var isLoading = false
#State var isErrorShown = false
var body: some View {
let overlayContainer = OverlayContainer(isLoading: $isLoading, isErrorShown: $isErrorShown)
return HStack {
// Some more content here
Button(action: {
overlayContainer.setIsLoading(isLoading: true)
}) {
Text("Start loading")
}
Button(action: {
overlayContainer.setIsErrorShown(isErrorShown: true)
}) {
Text("Show error")
}
}.overlay(overlayContainer)
}
}