What state am I modifying during view update? - ios

I have a CoreData object Day which I am editing within this view, when in "editing" mode the card allows for a Picker view and then a TextField to allow user input. These all work as expected, however when I tap "done" which runs the saveEditToDay() function which is just saving those into the Day variable. This crashes with Thread 1: EXC_BREAKPOINT (code=1, subcode=0x1cffc77e0) and gives the hint Modifying state during view update, this will cause undefined behavior.. I am not clear what state I modifying in this case?
Here is the view:
import SwiftUI
struct EditCard: View {
#Binding var day: Day?
var timeOfDay: Time
#State var isEditing: Bool = false
#State var selectedRating = 0
#State var description = ""
var ratings = Rating.getAllRatings()
// MARK: - Body
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(timeOfDay.rawValue).font(.headline)
Spacer()
Button(action: {
if self.isEditing { self.saveEditToDay() }
self.isEditing.toggle()
}, label: { self.isEditing ? Text("Done") : Text("Edit") })
.foregroundColor(Color("PrimaryColour"))
}
Divider()
VStack(alignment: .leading) {
Text("RATING")
.font(.caption)
.foregroundColor(Color(UIColor.systemGray))
if isEditing {
Picker(selection: $selectedRating, label: Text("")) {
ForEach(0 ..< ratings.count) {
Text(self.ratings[$0]).tag([$0])
}
}
} else {
Text(day!.getRating(for: timeOfDay))
}
}.padding(.trailing)
VStack(alignment: .leading) {
Text("REASON")
.font(.caption)
.foregroundColor(Color(UIColor.systemGray))
if isEditing {
TextField(description, text: $description)
} else {
Text(description)
.multilineTextAlignment(.leading)
.lineLimit(nil)
}
}
}
.padding()
.background(Color(UIColor.secondarySystemGroupedBackground))
.cornerRadius(10)
.onAppear { self.setSelected() }
.onDisappear{ self.saveEditToDay(); print("Toodles") }
}
// MARK: - Functions
func setSelected() {
guard let day = day else { return }
self.selectedRating = ratings.firstIndex(of: day.getRating(for: timeOfDay))!
self.description = day.getDescription(for: timeOfDay)
}
func saveEditToDay() {
guard let day = day else { return }
day.setRating(rating: ratings[selectedRating], for: timeOfDay)
day.setDescription(description: description, for: timeOfDay)
}
}
Here at the Extensions to the NSManagedObject that i'm calling:
func setRating(rating: String, for time: Time) {
switch time {
case .morning: self.morning = rating;
case .afternoon: self.afternoon = rating;
case .evening: self.evening = rating;
}
}
func setDescription(description: String, for time: Time) {
switch time {
case .morning: self.morningDescription = description;
case .afternoon: self.afternoonDescription = description;
case .evening: self.eveningDescription = description;
}
}

You are calling setSelected() in onApear(), which modifies two of your variables marked as #State: selectedRating and description. This means that as the view gets drawn or redrawn you are changing the variables that define what is being drawn halfway through the process.
onApear() is a good place to trigger a background refresh, but not modify any #State variables instantly.
You probably should write an initialiser that takes binding to a Day and Time sets the other two variables just like you do in setSelected().

Related

How to update filtered list in swiftui, when the value in the filter is changed?

Usual caveat of being new to swiftui and apologies is this is a simple question.
I have a view where I have a date picker, as well as two arrows to increase/decrease the day. When this date is update, I am trying to filter a list of 'sessions' from the database which match the currently displayed date.
I have a filteredSessions variable which applies a filter to all 'sessions' from the database. However I do not seem to have that filter refreshed each time the date is changed.
I have the date to be used stored as a "#State" object in the view. I thought this would trigger the view to update whenever that field is changed? However I have run the debugger and found the 'filteredSessions' variable is only called once, and not when the date is changed (either by the picker or the buttons).
Is there something I'm missing here? Do I need a special way to 'bind' this date value to the list because it isn't directly used by the display?
Code below. Thanks
import SwiftUI
struct TrainingSessionListView: View {
#StateObject var viewModel = TrainingSessionsViewModel()
#State private var displayDate: Date = Date.now
#State private var presentAddSessionSheet = false
private var dateManager = DateManager()
private let oneDay : Double = 86400
private var addButton : some View {
Button(action: { self.presentAddSessionSheet.toggle() }) {
Image(systemName: "plus")
}
}
private var decreaseDayButton : some View {
Button(action: { self.decreaseDay() }) {
Image(systemName: "chevron.left")
}
}
private var increaseDayButton : some View {
Button(action: { self.increaseDay() }) {
Image(systemName: "chevron.right")
}
}
private func sessionListItem(session: TrainingSession) -> some View {
NavigationLink(destination: TrainingSessionDetailView(session: session)) {
VStack(alignment: .leading) {
Text(session.title)
.bold()
Text("\(session.startTime) - \(session.endTime)")
}
}
}
private func increaseDay() {
self.displayDate.addTimeInterval(oneDay)
}
private func decreaseDay() {
self.displayDate.addTimeInterval(-oneDay)
}
var body: some View {
NavigationView {
VStack {
HStack {
Spacer()
decreaseDayButton
Spacer()
DatePicker("", selection: $displayDate, displayedComponents: .date)
.labelsHidden()
Spacer()
increaseDayButton
Spacer()
}
.padding(EdgeInsets(top: 25, leading: 0, bottom: 0, trailing: 0))
Spacer()
ForEach(filteredSessions) { session in
sessionListItem(session: session)
}
Spacer()
}
.navigationTitle("Training Sessions")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: addButton)
.sheet(isPresented: $presentAddSessionSheet) {
TrainingSessionEditView()
}
}
}
var filteredSessions : [TrainingSession] {
print("filteredSessions called")
return viewModel.sessions.filter { $0.date == dateManager.dateToStr(date: displayDate) }
}
}
struct TrainingSessionListView_Previews: PreviewProvider {
static var previews: some View {
TrainingSessionListView()
}
}
There are two approaches and for your case and for what you described I would take the first one. I only use the second approach if I have more complex filters and tasks
You can directly set the filter on the ForEach this will ensure it gets updated whenever the displayDate changes.
ForEach(viewModel.sessions.filter { $0.date == dateManager.dateToStr(date: displayDate) }) { session in
sessionListItem(session: session)
}
Or you can like CouchDeveloper said, introduce a new state variable and to trigger a State change you would use the willSet extension (doesn't exist in binding but you can create it)
For this second option you could do something like this.
Start create the Binding extension for the didSet and willSet
extension Binding {
func didSet(execute: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
let snapshot = self.wrappedValue
self.wrappedValue = $0
execute(snapshot)
}
)
}
func willSet(execute: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
execute($0)
self.wrappedValue = $0
}
)
}
}
Introduce the new state variable
#State var filteredSessions: [TrainingSession] = []
// removing the other var
We introduce the function that will update the State var
func filterSessions(_ filter: Date) {
filteredSessions = viewModel.sessions.filter { $0.date == dateManager.dateToStr(date: date) }
}
We update the DatePicker to run the function using the willSet
DatePicker("", selection: $displayDate.willSet { self.filterSessions($0) }, displayedComponents: .date)
And lastly we add a onAppear so we fill the filteredSessions immidiatly (if you want)
.onAppear { filterSessions(displayDate) } // uses the displayDate that you set as initial value
Don't forget in your increaseDay() and decreaseDay() functions to add the following after the addTimeInterval
self.filterSessions(displayDate)
As I said, this second method might be better for more complex filters
Thank you all for your responses. I'm not sure what the issue was originally but it seems updating my view to use Firebase's #FirestoreQuery to access the collection updates the var filteredSessions... much better than what I had before.
New code below seems to be working nicely now.
import SwiftUI
import FirebaseFirestoreSwift
struct TrainingSessionListView: View {
#FirestoreQuery(collectionPath: "training_sessions") var sessions : [TrainingSession]
#State private var displayDate: Date = Date.now
#State private var presentAddSessionSheet = false
private var dateManager = DateManager()
private let oneDay : Double = 86400
private var addButton : some View {
Button(action: { self.presentAddSessionSheet.toggle() }) {
Image(systemName: "plus")
}
}
private var todayButton : some View {
Button(action: { self.displayDate = Date.now }) {
Text("Today")
}
}
private var decreaseDayButton : some View {
Button(action: { self.decreaseDay() }) {
Image(systemName: "chevron.left")
}
}
private var increaseDayButton : some View {
Button(action: { self.increaseDay() }) {
Image(systemName: "chevron.right")
}
}
private func sessionListItem(session: TrainingSession) -> some View {
NavigationLink(destination: TrainingSessionDetailView(sessionId: session.id!)) {
VStack(alignment: .leading) {
Text(session.title)
.bold()
Text("\(session.startTime) - \(session.endTime)")
}
}
}
private func increaseDay() {
self.displayDate.addTimeInterval(oneDay)
}
private func decreaseDay() {
self.displayDate.addTimeInterval(-oneDay)
}
var body: some View {
NavigationView {
VStack {
HStack {
Spacer()
decreaseDayButton
Spacer()
DatePicker("", selection: $displayDate, displayedComponents: .date)
.labelsHidden()
Spacer()
increaseDayButton
Spacer()
}
.padding(EdgeInsets(top: 25, leading: 0, bottom: 10, trailing: 0))
if filteredSessions.isEmpty {
Spacer()
Text("No Training Sessions found")
} else {
List {
ForEach(filteredSessions) { session in
sessionListItem(session: session)
}
}
}
Spacer()
}
.navigationTitle("Training Sessions")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: todayButton, trailing: addButton)
.sheet(isPresented: $presentAddSessionSheet) {
TrainingSessionEditView()
}
}
}
var filteredSessions : [TrainingSession] {
return sessions.filter { $0.date == dateManager.dateToStr(date: displayDate)}
}
}
struct TrainingSessionListView_Previews: PreviewProvider {
static var previews: some View {
TrainingSessionListView()
}
}

Toggling from Picker to Image view causes an index out of range error in SwiftUI

I have a view that uses a button to toggle between a Picker and an Image that is a result of the Picker selection. When quickly toggling from the image to the Picker and immediately back, I get a crash with the following error:
Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range
Toggling less quickly doesn't cause this, nor does toggling in the other direction (picker to image and back). Here is the offending code:
import SwiftUI
struct ContentView: View {
#State private var showingPicker = false
#State private var currentNum = 0
#State private var numbers: [Int] = [1, 2, 3, 4, 5]
var body: some View {
VStack(spacing: 15) {
Spacer()
if showingPicker {
Picker("Number", selection: $currentNum) {
ForEach(0..<numbers.count, id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.wheel)
} else {
Image(systemName: "\(currentNum).circle")
}
Spacer()
Button("Toggle") {
showingPicker = !showingPicker
}
}
}
}
The code works otherwise. I'm new to SwiftUI so I'm still wrapping my head around how views are created/destroyed. I tried changing the order of the properties thinking maybe the array was being accessed before it was recreated(if that's even something that happens) but that had no effect. I also tried ForEach(numbers.indices) instead of ForEach(0..<numbers.count), but it has the same result.
**Edit
I figured out a stop-gap for now. I added #State private var buttonEnabled = true and modified the button:
Button("Toggle") {
showingPicker = !showingPicker
buttonEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
buttonEnabled = true
}
}
.disabled(buttonEnabled == false)
To debounce it. I still want to figure out the problem and make a real fix.
**Edit
Based on comments I've modified the code to take array indexing out of the equation and to better reflect the actual project I'm working on. The code still works, but a quick toggle will cause the exact same crash and error. It also seems to only happen when the .wheel style picker is used, other picker styles don't have this behavior.
enum Icons: String, CaseIterable, Identifiable {
case ear = "Ear"
case cube = "Cube"
case eye = "Eye"
case forward = "Forward"
case gear = "Gear"
func image() -> Image {
switch self {
case .ear:
return Image(systemName: "ear")
case .cube:
return Image(systemName: "cube")
case .eye:
return Image(systemName: "eye")
case .forward:
return Image(systemName: "forward")
case .gear:
return Image(systemName: "gear")
}
}
var id: Self {
return self
}
}
struct ContentView: View {
#State private var showingPicker = false
#State private var currentIcon = Icons.allCases[0]
var body: some View {
VStack(spacing: 15) {
Spacer()
if showingPicker {
Picker("Icon", selection: $currentIcon) {
ForEach(Icons.allCases) {
$0.image()
}
}
.pickerStyle(.wheel)
} else {
currentIcon.image()
}
Spacer()
Button("Toggle") {
showingPicker.toggle()
}
}
}
}
** Edited once more to remove .self, still no change
ForEach is not a for loop, you can't use array.count and id:\.self you need to use a real id param or use the Identifiable protocol.
However if you just need numbers it also supports this:
ForEach(0..<5) { i in
As long as you don't try to look up an array using i.

if Array is empty, append all values in enum to array

First Dabble in SwiftUI, I've managed to get the below code working such that when I press a button, it will show a "selected" state and add the selected sports into an array. (and remove from the array if "deselected")
However, I can't figure out how to initialise the sportsArray with ALL values within the enum HelperIntervalsIcu.icuActivityType.allCases if it is initially empty.
I tried to put in
if sportsArray.isEmpty {
HelperIntervalsIcu.icuActivityType.allCases.forEach {
sportsArray.append($0.rawvalue)
}
but Xcode keeps telling me type() cannot conform to View or things along those lines
struct selectSports: View {
#State private var sportsArray = [String]()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
// https://stackoverflow.com/a/69455949/14414215
ForEach(Array(HelperIntervalsIcu.icuActivityType.allCases), id:\.rawValue) { sport in
Button(action: {
addSports(sport: sport.rawValue)
}) {
HStack {
Image(getSportsIcon(sport: sport.rawValue))
.selectedSportsImageStyle(sportsArray: sportsArray, sport: sport.rawValue)
Text(sport.rawValue)
}
}
.buttonStyle(SelectedSportButtonStyle(sportsArray: sportsArray, sport: sport.rawValue))
}
}
}
}
struct SelectedSportButtonStyle: ButtonStyle {
var sportsArray: [String]
var sport: String
var selectedSport : Bool {
if sportsArray.contains(sport) {
return true
} else {
return false
}
}
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(selectedSport ? Font.subheadline.bold() : Font.subheadline)
.aspectRatio(contentMode: .fit)
.foregroundColor(selectedSport ? Color.orange : Color(UIColor.label))
.padding([.leading, .trailing], 15)
.padding([.top, .bottom],10)
.overlay(
RoundedRectangle(cornerRadius: 5.0)
.stroke(lineWidth: 2.0)
.foregroundColor(selectedSport ? Color.orange : Color.gray)
)
.offset(x: 10, y: 0)
}
}
func addSports(sport: String) {
if sportsArray.contains(sport) {
let sportsIndex = sportsArray.firstIndex(where: { $0 == sport })
sportsArray.remove(at: sportsIndex!)
} else {
sportsArray.append(sport)
}
print("Selected Sports:\(sportsArray)")
}
}
No Sports Selected (in this case sportsArray is empty and thus the default state which I would like have would be to have ALL sports Pre-selected)
2 Sports Selected
I assume you wanted to do that in onAppear, like
struct selectSports: View {
#State private var sportsArray = [String]()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
// .. other code
}
.onAppear {
if sportsArray.isEmpty { // << here !!
HelperIntervalsIcu.icuActivityType.allCases.forEach {
sportsArray.append($0.rawvalue)
}
}
}
}
}

Saving Int to Core Data SWIFTUI

Below is a view within my Task management app, and I am trying to make my picker display a certain color when it is chosen. My question is how do I save the Ints used for the priority and color picker within Core data? I can't figure out how to convert the int value into int32, and it can't start as an Int32 object because then it wouldn't be able to be used as the selector for the background color of each picker. PLEASE HELP!
struct NewTask: View {
#Environment(\.dismiss) var dismiss
// MARK: Task Values
#State var taskTitle: String = ""
#State var taskDescription: String = ""
#State var taskDate: Date = Date()
var colors = ["Teal","Yellow","Pink"]
#State var selectedColor: Int = 0
var colorsColors = ["logoTeal","logoYellow","logoPink"]
var selectedColorInt: String {
get {
return ("\(selectedColor)")
}
}
var priorities = ["Urgent","Slightly Urgent","Not Urgent"]
#State var selectedPriority: Int = 0
var priorityColors = ["priorityRed","priorityYellow","priorityGreen"]
// MARK: Core Data Context
#Environment(\.managedObjectContext) var context
#EnvironmentObject var taskModel: TaskViewModel
var body: some View {
NavigationView{
List{
Section {
TextField("Go to work", text: $taskTitle)
Picker("Priority", selection: $selectedPriority) {
ForEach(0..<priorities.count){
Text(priorities[$0])
}
.padding(5)
.foregroundColor(.white)
.background(priorityColors[selectedPriority])
.cornerRadius(5)
}
Picker("Theme", selection: $selectedColor) {
ForEach(0..<colors.count){
Text(colors[$0])
}
.padding(5)
.foregroundColor(.black)
.background(colorColors[selectedColor])
.cornerRadius(5)
}
} header: {
Text("Task Information")
}
Section {
TextEditor(text: $taskDescription)
} header: {
Text("Task Description")
}
// Disabling Date for Edit Mode
if taskModel.editTask == nil{
Section {
DatePicker("", selection: $taskDate)
.datePickerStyle(.graphical)
.labelsHidden()
} header: {
Text("Task Date")
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Add New Task")
.navigationBarTitleDisplayMode(.inline)
// MARK: Disbaling Dismiss on Swipe
.interactiveDismissDisabled()
// MARK: Action Buttons
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save"){
if let task = taskModel.editTask{
task.taskTitle = taskTitle
task.taskDescription = taskDescription
}
else{
let task = Task(context: context)
task.taskTitle = taskTitle
task.taskDescription = taskDescription
task.taskDate = taskDate
/*task.selectedColor = selectedColor*/
/*task.selectedPriority = selectedPriority*/
}
// Saving
try? context.save()
// Dismissing View
dismiss()
}
.disabled(taskTitle == "" || taskDescription == "")
}
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel"){
dismiss()
}
}
}
// Loading Task data if from Edit
.onAppear {
if let task = taskModel.editTask{
taskTitle = task.taskTitle ?? ""
taskDescription = task.taskDescription ?? ""
}
}
}
}
}
Here's the approach I would take.
First, as priority will only ever be one of three values, express it as an enum with an Int32 raw value, and with the text description and colours as properties of the enum:
enum Priority: Int32, CaseIterable {
case urgent = 0
case slightlyUrgent = 1
case notUrgent = 2
var description: String {
switch self {
case .urgent: return "Urgent"
case .slightlyUrgent: return "Slightly Urgent"
case .notUrgent: return "Not Urgent"
}
}
var colorName: String {
switch self {
case .urgent: return "PriorityRed"
case .slightlyUrgent: return "PriorityYellow"
case .notUrgent: return "PriorityGreen"
}
}
var color: Color { Color(colorName) }
}
In the form, this keeps your code a bit cleaner, and allows you to use semantic values over 0 when initialising your state variable:
#State private var selectedPriority: Priority = .urgent
Picker("Priority", selection: $selectedPriority) {
ForEach(Priority.allCases, id: \.self) { priority in
Text(priority.description)
.padding(5)
.foregroundColor(.black)
.background(priority.color)
.cornerRadius(5)
}
}
Then, add an extension to your CoreData model that gives it a Priority property, adding a getter and setter so that it uses the stored column:
extension Task {
var priorityEnum: Priority {
get { Priority(rawValue: priority) ?? .urgent }
set { priority = newValue.rawValue }
}
}
Then when it comes to save the form details into the Core Data managed object, you can set task.priorityEnum to the picker's value, and the custom property will make sure that the correct Int32 value is stored.
Likewise, when loading the edit form's state variables from the task, referencing task.priorityEnum will get you a Priority value initialized with the integer that's stored in the database.

State not updating correctly in SwiftUI

Am trying to implement like and unlike button, to allow a user to like a card and unlike. When a user likes a button the id is saved and the icon changes from line heart to filled heart. I can save the id correctly but the issue is that at times the icon does not switch to filled one to show the user the changes especially after selecting the first one. The subsequent card won't change state but remain the same, while it will add save the id correctly. To be able to see the other card I have to, unlike the first card it cand display both like at the same time. I have tried both Observable and Environmental.
My Class to handle like and unlike
import Foundation
import Disk
class FavouriteRest: ObservableObject {
#Published private var fav = [Favourite]()
init() {
getFav()
}
func getFav(){
if let retrievedFav = try? Disk.retrieve("MyApp/favourite.json", from: .documents, as: [Favourite].self) {
fav = retrievedFav
} else {
print("")
}
}
//Get single data
func singleFave(id: String) -> Bool {
for x in fav {
if id == x.id {
return true
}
return false
}
return false
}
func addFav(favourite: Favourite){
if singleFave(id: favourite.id) == false {
self.fav.append(favourite)
self.saveFave()
}
}
//Remove Fav
func removeFav(_ favourite: Favourite) {
if let index = fav.firstIndex(where: { $0.id == favourite.id }) {
fav.remove(at: index)
saveFave()
}
}
//Save Fav
func saveFave(){
do {
try Disk.save(self.fav, to: .documents, as: "SmartParking/favourite.json")
}
catch let error as NSError {
fatalError("""
Domain: \(error.domain)
Code: \(error.code)
Description: \(error.localizedDescription)
Failure Reason: \(error.localizedFailureReason ?? "")
Suggestions: \(error.localizedRecoverySuggestion ?? "")
""")
}
}
}
Single Card
#EnvironmentObject var favourite:FavouriteRest
HStack(alignment: .top){
VStack(alignment: .leading, spacing: 4){
Text(self.myViewModel.myModel.title)
.font(.title)
.fontWeight(.bold)
Text("Some text")
.foregroundColor(Color("Gray"))
.font(.subheadline)
.fontWeight(.bold)
}
Spacer()
VStack{
self.favourite.singleFave(id: self.myViewModel.myModel.id) ? Heart(image: "suit.heart.fill").foregroundColor(Color.red) : Heart(image: "suit.heart").foregroundColor(Color("Gray"))
}
.onTapGesture {
if self.favourite.singleFave(id: self.myViewModel.myModel.id) {
self.favourite.removeFav(Favourite(id: self.myViewModel.myModel.id))
} else {
self.favourite.addFav(favourite: Favourite(id: self.myViewModel.myModel.id))
}
}
}
I was able to solve the question by moving the code inside the card view and using #States as shown below.
#State private var fav = [Favourite]()
#State var liked = false
VStack{
// Heading
HStack(alignment: .top){
VStack{
self.liked ? Heart(image:"suit.heart.fill").foregroundColor(Color.red) : Heart(image: "suit.heart").foregroundColor(Color("Gray"))
}
.onTapGesture {
if self.liked {
self.removeFav(self.singleFav!)
} else {
let faveID = self.myViewModel.myModel.id
let myFav = Favourite(id:faveID)
self.fav.append(myFav)
self.saveFave()
}
}
}
In the method for fetch and remove, I updated the #State var liked. Everything working as expected now.

Resources