Change values in struct model - swift

I'm stuck with adding items from struct to favorites. The idea:
I have a json with data for cards
On the main screen app shows a random card
The user could pick another random card or save it to favorites.
Code below.
Creating a CardModel (file 1):
struct CardModel: Hashable, Codable, Identifiable {
let id: Int
let topic: String
let category: String
var saved: Bool
}
Retrieving data from json and creating an array of structs (file 2):
var cardsModelArray: [CardModel] = load("LetsTalkTopics.json")
Func to pickup random item from the array (file 3):
func pickRandomCard() -> CardModel {
let randomCard = cardsModelArray.randomElement()!
return randomCard
}
Func to change the "saved" bool value (file 4)
func saveCard(card: CardModel) {
let index = card.id
cardsModelArray[index] = CardModel(id: index, topic: card.topic, category: card.category, saved: !card.saved)
}
View file (file 5, simplified)
import SwiftUI
struct StackOverFlow: View {
#State var currentCard = pickRandomCard()
var body: some View {
VStack{
CardViewStackOver(cards: currentCard)
Button("Show random card") {
currentCard = pickRandomCard()
}
Button("Save item") {
saveCard(card: currentCard)
}
}
}
struct StackOverFlow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
StackOverFlow()
}
}
}
struct CardViewStackOver: View {
let cards: CardModel
var body: some View {
VStack {
Text(cards.topic)
Text(cards.category)
Text(String(cards.id))
HStack {
if cards.saved {
Image(systemName: "heart.fill")
.font(.title)
.padding(15)
.padding(.bottom, 20)
} else {
Image(systemName: "heart")
.font(.title)
.padding(15)
.padding(.bottom, 20)
}
}
}
}
}
But I definitely making something wrong, in a separate view I'm showing saved cards but it doesn't work (it shows some random cards, and some saved cards have duplicates). With my research, I found out that structs are immutable and when I'm trying to edit a value, basically swift creates a copy of it and makes changes in the copy. If so, what would be the right approach to create this favorite feature?

A solution that fixed the problem:
func saveCardMinus(currentCard: CardModel) {
var index = currentCard.id - 1
cardsModelArray[index].saved.toggle()
}
But I'm sure that the whole solution is bad. What is the right/more proper way to solve it?
(and btw, now I face another problem: the icon for bool value updates is not in real-time, you need to open this card again to see a new value (filled heart/unfilled heart))

You could use a Binding to pass the selected card on. But I restructured the whole code as there were multiple bad practices involved:
struct StackOverFlow: View {
//store the current selected index and the collection as state objects
#State var currentCardIndex: Int?
#State var cards: [CardModel] = []
var body: some View {
VStack{
//if there is an index show the card
if let index = currentCardIndex{
CardViewStackOver(card: $cards[index])
}
Button("Show random card") {
// just create a random index
currentCardIndex = (0..<cards.count).randomElement()
}
Button("Save / delete item") {
if let index = currentCardIndex{
//you save delete as favorite here
cards[index].saved.toggle()
}
}
}.onAppear{
//don´t exactly know where this function lives
if cards.count == 0{
cards = load("LetsTalkTopics.json")
}
}
}
}
struct CardViewStackOver: View {
//use binding wrapper here
#Binding var card: CardModel
var body: some View {
VStack {
Text(card.topic)
Text(card.category)
Text(String(card.id))
HStack {
Image(systemName: card.saved ? "heart.fill" : "heart")
.font(.title)
.padding(15)
.padding(.bottom, 20)
.onTapGesture {
card.saved.toggle()
}
}
}
}
}

Related

SwiftUI nested LazyVStacks in a single ScrollView

I'm trying to build a comment thread. So top level comments can all have nested comments and so can they and so on and so forth. But I'm having issues around scrolling and also sometimes when expanding sections the whole view just jumps around, and can have a giant blank space at the bottom. The code looks like this:
struct ContentView: View {
var body: some View {
VStack {
HStack {
Text("Comments")
.font(.system(size: 34))
.fontWeight(.bold)
Spacer()
}
.padding()
CommentListView(commentIds: [0, 1, 2, 3], nestingLevel: 1)
}
}
}
struct CommentListView: View {
let commentIds: [Int]?
let nestingLevel: Int
var body: some View {
if let commentIds = commentIds {
LazyVStack(alignment: .leading) {
ForEach(commentIds, id: \.self) { id in
CommentItemView(viewModel: CommentItemViewModel(commentId: id), nestingLevel: nestingLevel)
}
}
.applyIf(nestingLevel == 1) {
$0.scrollable()
}
} else {
Spacer()
Text("No comments")
Spacer()
}
}
}
struct CommentItemView: View {
#StateObject var viewModel: CommentItemViewModel
let nestingLevel: Int
#State private var showComments = false
var body: some View {
VStack {
switch viewModel.viewState {
case .error:
Text("Error")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .loading:
Text("Loading")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .complete:
VStack {
Text(viewModel.text)
.padding(.bottom)
.padding(.leading, 20 * CGFloat(nestingLevel))
if let commentIds = viewModel.commentIds {
Button {
withAnimation {
showComments.toggle()
}
} label: {
Text(showComments ? "Hide comments" : "Show comments")
}
if showComments {
CommentListView(commentIds: commentIds, nestingLevel: nestingLevel + 1)
}
}
}
}
}
}
}
class CommentItemViewModel: ObservableObject {
#Published private(set) var text = ""
#Published private(set) var commentIds: [Int]? = [0, 1, 2, 3]
#Published private(set) var viewState: ViewState = .loading
private let commentId: Int
private var viewStateInternal: ViewState = .loading {
willSet {
withAnimation {
viewState = newValue
}
}
}
init(commentId: Int) {
self.commentId = commentId
fetchComment()
}
private func fetchComment() {
viewStateInternal = .complete
text = CommentValue.allCases[commentId].rawValue
}
}
Has anyone got a better way of doing this? I know List can now accept a KeyPath to child object and it can nest that way, but there's so limited design control over List that I didn't want to use it. Also, while this code is an example, the real code will have to load each comment from an API call, so List won't perform as well as LazyVStack in that regard.
Any help appreciated - including a complete overhaul of how to implement this sort of async loading nested view.

SwiftUI: How to retrieve data from Firestore in second view?

This is my first SwiftUI project and am very new in programming.
I have a View Model and a picker for my Content View, manage to send $selection to Detail View but not sure how to get it to read from the Firestore again. I kinda feel like something is missing, like I need to use an if-else, but can't pinpoint exactly what is the problem. Searched through the forum here but can't seemed to find a solution.
Here is the VM
import Foundation
import Firebase
class FoodViewModel: ObservableObject {
#Published var datas = [Food]()
private var db = Firestore.firestore()
func fetchData(){
db.collection("meals").addSnapshotListener { (snap, err) in
DispatchQueue.main.async {
if err != nil {
print((err?.localizedDescription)!)
return
} else {
for i in snap!.documentChanges{
let id = i.document.documentID
let name = i.document.get("name") as? String ?? ""
let weight = i.document.get("weight") as? Int ?? 0
let temp = i.document.get("temp") as? Int ?? 0
let time = i.document.get("time") as? Int ?? 0
self.datas.append(Food(id: id, name: name, weight: weight, temp: temp, time: time))
}
}
}
}
}
}
struct ContentView: View {
#ObservedObject var foodDatas = FoodViewModel()
#State private var id = ""
#State public var selection: Int = 0
var body: some View {
NavigationView{
let allFood = self.foodDatas.datas
ZStack {
Color("brandBlue")
.edgesIgnoringSafeArea(.all)
VStack {
Picker(selection: $selection, label: Text("Select your food")) {
ForEach(allFood.indices, id:\.self) { index in
Text(allFood[index].name.capitalized).tag(index)
}
}
.onAppear() {
self.foodDatas.fetchData()
}
Spacer()
NavigationLink(
destination: DetailView(selection: self.$selection),
label: {
Text("Let's make this!")
.font(.system(size: 20))
.fontWeight(.bold)
.foregroundColor(Color.black)
.padding(12)
})
}
And after the picker selected a food type, I hope for it to display the rest of details such as cooking time and temperature. Now it is displaying 'index out of range' for the Text part.
import SwiftUI
import Firebase
struct DetailView: View {
#ObservedObject var foodDatas = FoodViewModel()
#Binding var selection: Int
var body: some View {
NavigationView{
let allFood = self.foodDatas.datas
ZStack {
Color("brandBlue")
.edgesIgnoringSafeArea(.all)
VStack {
Text("Cooking Time: \(allFood[selection].time) mins")
}
}
}
}
Appreciate for all the help that I can get.
In your DetailView, you're creating a new instance of FoodViewModel:
#ObservedObject var foodDatas = FoodViewModel()
That new instance has not fetched any data, and thus the index of the selection is out of bounds, because its array is empty.
You could pass your original ContentView's copy of the FoodDataModel as a parameter.
So, the previous line I quoted would become:
#ObservedObject var foodDatas : FoodViewModel
And then your NavigationLink would look like this:
NavigationLink(destination: DetailView(foodDatas: foodDatas, selection: self.$selection) //... etc

SwiftUI - Scroll a list to a specific element controlled by external variable

I have a List populated by Core Data, like this:
#EnvironmentObject var globalVariables : GlobalVariables
#Environment(\.managedObjectContext) private var coreDataContext
#FetchRequest(fetchRequest: Expressao.getAllItemsRequest())
private var allItems: FetchedResults<Expressao>
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(allItems,
id: \.self) { item in
Text(item.term!.lowercased())
.id(allItems.firstIndex(of:item))
.listRowBackground(
Group {
if (globalVariables.selectedItem == nil) {
Color(UIColor.clear)
} else if item == globalVariables.selectedItem {
Color.orange.mask(RoundedRectangle(cornerRadius: 20))
} else {
nextAlternatedColor(item:item)
}
}
}
}
}
}
}
Every time a row is selected it changes color to orange. So, you see that the color is controlled by an external variable located in globalVariables.selectedItem.
I want to be able to make the list scroll to that element on globalVariables.selectedItem automatically.
How do I do that with ScrollViewReader?
Any ideas?
Here is a demo of possible approach - scrollTo can be used only in closure, so the idea is to create some background view depending on row to be scrolled to (this can be achieved with .id) and attach put .scrollTo in .onAppear of that view.
Tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var row = 0
var body: some View {
VStack {
// button here is generator of external selection
Button("Go \(row)") { row = Int.random(in: 0..<50) }
ScrollViewReader { proxy in
List {
ForEach(0..<50) { item in
Text("Item \(item)")
.id(item)
}
}
.background( // << start !!
Color.clear
.onAppear {
withAnimation {
proxy.scrollTo(row, anchor: .top)
}
}.id(row)
) // >> end !!
}
}
}
}

Fatal error: Index out of range in SwiftUI

I made a practice app where the main view is a simple list. When the item of the list is tapped, it presents the detail view. Inside the detail view is a “textField” to change the items title.
I always have the error by making this steps:
add 3 items to the list
change the title of the second item
delete the third item
delete the second item (the one you changed the title.
When you delete the item that you changed the name, the app will crash and presente me the following error: “Fatal error: Index out of range in SwiftUI”
How can I fix it?
The main view:
struct ContentView: View {
#EnvironmentObject var store: CPStore
var body: some View {
NavigationView {
VStack {
List {
ForEach(0..<store.items.count, id:\.self) { index in
NavigationLink(destination: Detail(index: index)) {
VStack {
Text(self.store.items[index].title)
}
}
}
.onDelete(perform: remove)
}
Spacer()
Button(action: {
self.add()
}) {
ZStack {
Circle()
.frame(width: 87, height: 87)
}
}
}
.navigationBarTitle("Practice")
.navigationBarItems(trailing: EditButton())
}
}
func remove(at offsets: IndexSet) {
withAnimation {
store.items.remove(atOffsets: offsets)
}
}
func add() {
withAnimation {
store.items.append(CPModel(title: "Item \(store.items.count + 1)"))
}
}
}
The detail view:
struct Detail: View {
#EnvironmentObject var store: CPStore
let index: Int
var body: some View {
VStack {
//Error is here
TextField("Recording", text: $store.items[index].title)
}
}
}
The model:
struct CPModel: Identifiable {
var id = UUID()
var title: String
}
And view model:
class CPStore: ObservableObject {
#Published var items = [CPModel]()
}
Instead of getting the number of items in an array and using that index to get items from your array of objects, you can get each item in the foreach instead. In your content view, change your For each to this
ForEach(store.items, id:\.self) { item in
NavigationLink(destination: Detail(item: item)) {
VStack {
Text(item.title)
}
}
}
And Change your detail view to this:
struct Detail: View {
#EnvironmentObject var store: CPStore
#State var item: CPModel
var body: some View {
VStack {
//Error is here
TextField("Recording", text: $item.title)
}
}
}
My guess is that when you delete item index 1, Detail for index 1 is triggered to re-render before ContentView is triggered. Since SwiftUI doesn't know index has to be updated first because index is a value type (independent of anything).
As another answer has already pointed out, give it a self-contained copy should solve the problem.
In general you should avoid saving a copy of the index since you now have to maintain consistency at all times between two sources of truth.
In terms of your usage, index is implying "index that is currently legit", which should come from your observed object.

'Fatal error: index out of range' when deleting bound object in view

I am having some trouble avoiding index out of range errors when modifying an array that a child view depends on a bound object from.
I have a parent view called WorkoutList. WorkoutList has an EnvironmentObject of ActiveWorkoutStore. ActiveWorkoutStore is an ObservableObject that has an array of Workout objects. I have a list of active workouts being retrieved from ActiveWorkoutStore. I'm using a ForEach loop to work with the indices of these active workouts and pass an object binding to a child view called EditWorkout as a destination for a NavigationLink. EditWorkout has a button to finish a workout, which removes it from ActiveWorkoutStore's array of workouts and adds it to WorkoutHistoryStore. I'm running into trouble when I remove this object from ActiveWorkoutStore's activeWorkouts array, immediately causing an index out of range error. I'm suspecting this is because the active view relies on a bound object that I've just deleted. I've tried a couple permutations of this, including passing a workout to EditWorkout, then using its id to reference a workout in ActiveWorkoutStore to perform my operations, but run into similar troubles. I've seen a lot of examples online that follow this pattern of leveraging ForEach to iterate over indices and I've mirrored it as best I can tell, but I suspect I may be missing a nuance to the approach.
I've attached code samples below. Let me know if you have any questions or if there's anything else I should include! Thanks in advance for your help!
WorkoutList (Parent View)
import SwiftUI
struct WorkoutList: View {
#EnvironmentObject var activeWorkoutsStore: ActiveWorkoutStore
#State private var addExercise = false
#State private var workoutInProgress = false
var newWorkoutButton: some View {
Button(action: {
self.activeWorkoutsStore.newActiveWorkout()
}) {
Text("New Workout")
Image(systemName: "plus.circle")
}
}
var body: some View {
NavigationView {
Group {
if activeWorkoutsStore.activeWorkouts.isEmpty {
Text("No active workouts")
} else {
List {
ForEach(activeWorkoutsStore.activeWorkouts.indices.reversed(), id: \.self) { activeWorkoutIndex in
NavigationLink(destination: EditWorkout(activeWorkout: self.$activeWorkoutsStore.activeWorkouts[activeWorkoutIndex])) {
Text(self.activeWorkoutsStore.activeWorkouts[activeWorkoutIndex].id.uuidString)
}
}
}
}
}
.navigationBarTitle(Text("Active Workouts"))
.navigationBarItems(trailing: newWorkoutButton)
}
}
}
EditWorkout (Child View)
//
// EditWorkout.swift
// workout-planner
//
// Created by Dominic Minischetti III on 11/2/19.
// Copyright © 2019 Dominic Minischetti. All rights reserved.
//
import SwiftUI
struct EditWorkout: View {
#EnvironmentObject var workoutHistoryStore: WorkoutHistoryStore
#EnvironmentObject var activeWorkoutStore: ActiveWorkoutStore
#EnvironmentObject var exerciseStore: ExerciseStore
#Environment(\.presentationMode) var presentationMode
#State private var addExercise = false
#Binding var activeWorkout: Workout
var currentDayOfWeek: String {
let weekdayIndex = Calendar.current.component(.weekday, from: Date())
return Calendar.current.weekdaySymbols[weekdayIndex - 1]
}
var chooseExercisesButton: some View {
Button (action: {
self.addExercise = true
}) {
HStack {
Image(systemName: "plus.square")
Text("Choose Exercises")
}
}
.sheet(isPresented: self.$addExercise) {
AddWorkoutExercise(exercises: self.$activeWorkout.exercises)
.environmentObject(self.exerciseStore)
}
}
var saveButton: some View {
Button(action: {
self.workoutHistoryStore.addWorkout(workout: self.$activeWorkout.wrappedValue)
self.activeWorkoutStore.removeActiveWorkout(workout: self.$activeWorkout.wrappedValue)
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Finish Workout")
}
.disabled(self.$activeWorkout.wrappedValue.exercises.isEmpty)
}
var body: some View {
Form {
Section(footer: Text("Choose which exercises are part of this workout")) {
chooseExercisesButton
}
Section(header: Text("Exercises")) {
if $activeWorkout.wrappedValue.exercises.isEmpty {
Text("No exercises")
} else {
ForEach(activeWorkout.exercises.indices, id: \.self) { exerciseIndex in
NavigationLink(destination: EditWorkoutExercise(exercise: self.$activeWorkout.exercises[exerciseIndex])) {
VStack(alignment: .leading) {
Text(self.activeWorkout.exercises[exerciseIndex].name)
Text("\(self.activeWorkout.exercises[exerciseIndex].sets.count) Set\(self.activeWorkout.exercises[exerciseIndex].sets.count == 1 ? "" : "s")")
.font(.footnote)
.opacity(0.5)
}
}
}
saveButton
}
}
}
.navigationBarTitle(Text("Edit Workout"), displayMode: .inline )
}
}
ActiveWorkoutStore
import Foundation
import Combine
class ActiveWorkoutStore: ObservableObject {
#Published var activeWorkouts: [Workout] = []
func newActiveWorkout() {
activeWorkouts.append(Workout())
}
func saveActiveWorkout(workout: Workout) {
let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id })!
activeWorkouts[workoutIndex] = workout
}
func removeActiveWorkout(workout: Workout) {
if let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id }) {
activeWorkouts.remove(at: workoutIndex)
}
}
}
Workout
import SwiftUI
struct Workout: Hashable, Codable, Identifiable {
var id = UUID()
var date = Date()
var exercises: [WorkoutExercise] = []
}
ForEach<Range> is constant range container (pay attention on below description of constructor), it is not allowed to modify it after construction.
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a *constant*
/// range.
///
/// This instance only reads the initial value of `data` and so it does not
/// need to identify views across updates.
///
/// To compute views on demand over a dynamic range use
/// `ForEach(_:id:content:)`.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
If you want to modify container, you have to use ForEach(activeWorkout.exercises)