Make view not redraw after updating ObservableObject - swift

I have a situation where I am using LazyVGrid to display Images from a users Photo library. The grid has sections, and each section contains an array of images which are to be displayed, along with some meta data about the section.
The problem I am having is that each time the user clicks on a tile, which toggles markedForDeletion in the corresponding Image, all of the views in the Grid (which are visible) redraw. This isn't ideal, as in the "real" version of the sample code there is a real penalty to redrawing each Tile, as images must be retrieved and rendered.
I have tried to make Tile conform to Equatable, and then use the .equatable() modifier to notify SwiftUI that the Tile shouldn't be redrawn, but this doesn't work.
Placing another .onAppear outside the Section hints that the entire section is being redrawn each time anything changes, but I'm not sure how to structure my code so that the impact of redrawing expensive Tiles is minimised.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: ViewModel = ViewModel()
let columns = [
GridItem(.flexible(minimum: 40), spacing: 0),
GridItem(.flexible(minimum: 40), spacing: 0),
]
var body: some View {
GeometryReader { gr in
ScrollView {
LazyVGrid(columns: columns) {
ForEach(viewModel.imageSections.indices, id: \.self) { imageSection in
Section(header: Text("Section!")) {
ForEach(viewModel.imageSections[imageSection].images.indices, id: \.self) { imageIndex in
Tile(image: viewModel.imageSections[imageSection].images[imageIndex])
.equatable()
.onTapGesture {
viewModel.imageSections[imageSection].images[imageIndex].markedForDeletion.toggle()
}
.overlay(Color.blue.opacity(viewModel.imageSections[imageSection].images[imageIndex].markedForDeletion ? 0.2 : 0))
.id(UUID())
}
}
}
}
}
}
}
}
struct Image {
var id: String = UUID().uuidString
var someData: String
var markedForDeletion: Bool = false
}
struct ImageSection {
var id: String = UUID().uuidString
var images: [Image]
}
class ViewModel: ObservableObject {
#Published var imageSections: [ImageSection] = generateFakeData(numSections: 10, numImages: 10)
}
func generateFakeData(numSections: Int, numImages: Int) -> [ImageSection] {
var sectionsToReturn: [ImageSection] = []
for _ in 0 ..< numSections {
var imageArray: [Image] = []
for i in 0 ..< numImages {
imageArray.append(Image(someData: "Data \(i)"))
}
sectionsToReturn.append(ImageSection(images: imageArray))
}
return sectionsToReturn
}
struct Tile: View, Equatable {
static func == (lhs: Tile, rhs: Tile) -> Bool {
return lhs.image.id == rhs.image.id
}
var image: Image
var body: some View {
Text(image.someData)
.background(RoundedRectangle(cornerRadius: 20).fill(Color.blue))
.onAppear(perform: {
NSLog("Appeared - \(image.id)")
})
}
}

Main reason of global redraw is .id(UUID()), because on every call it force view recreation. But to remove it and keep ForEach operable we need to make model identifiable explicitly.
Here is fixed parts of code. Tested with Xcode 12.1 / iOS 14.1
main cycle
ForEach(Array(viewModel.imageSections.enumerated()), id: \.1) { i, imageSection in
Section(header: Text("Section!")) {
ForEach(Array(imageSection.images.enumerated()), id: \.1) { j, image in
Tile(image: image)
.onTapGesture {
viewModel.imageSections[i].images[j].markedForDeletion.toggle()
}
.overlay(Color.red.opacity(image.markedForDeletion ? 0.2 : 0))
}
}
}
models (note - your Image conflicts with standards SwiftUI Image - avoid such conflicts otherwise you might get into very unclear errors. For demo I renamed it everywhere needed to ImageM)
struct ImageM: Identifiable, Hashable {
var id: String = UUID().uuidString
var someData: String
var markedForDeletion: Bool = false
}
struct ImageSection: Identifiable, Hashable {
var id: String = UUID().uuidString
var images: [ImageM]
}

Related

Index out of range when updating a #Published var

I am new to Swift/SwiftUI and am trying to build an app that works with the trello API.
There is a "TrelloApi" class that is available as an #EnvironmentObject in the entire app. The same class is also used to make API calls.
One board is viewed at a time. A board has many lists and each list has many cards.
Now I have an issue with my rendering where whenever I switch boards and any list in the new board has fewer cards in it than before, I get the following error in an onReceive handler where I need to do some checks to update the cards appearance:
Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range
2022-10-19 09:04:11.319982+0200 trello[97617:17713580] Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range
Models
struct BoardPrefs: Codable {
var backgroundImage: String? = "";
}
struct BasicBoard: Identifiable, Codable {
var id: String;
var name: String;
var prefs: BoardPrefs;
}
struct Board: Identifiable, Codable {
var id: String;
var name: String;
var prefs: BoardPrefs;
var lists: [List] = [];
var cards: [Card] = [];
var labels: [Label] = [];
}
struct List: Identifiable, Codable, Hashable {
var id: String;
var name: String;
var cards: [Card] = [];
private enum CodingKeys: String, CodingKey {
case id
case name
}
}
struct Card: Identifiable, Codable, Hashable {
var id: String;
var idList: String = "";
var labels: [Label] = [];
var idLabels: [String] = [];
var name: String;
var desc: String = "";
var due: String?;
var dueComplete: Bool = false;
}
TrelloApi.swift (HTTP call removed for simplicity)
class TrelloApi: ObservableObject {
let key: String;
let token: String;
#Published var board: Board;
#Published var boards: [BasicBoard];
init(key: String, token: String) {
self.key = key
self.token = token
self.board = Board(id: "", name: "", prefs: BoardPrefs())
self.boards = []
}
func getBoard(id: String, completion: #escaping (Board) -> Void = { board in }) {
if id == "board-1" {
self.board = Board(id: "board-1", name: "board-1", prefs: BoardPrefs(), lists: [
List(id: "board-1-list-1", name: "board-1-list-1", cards: [
Card(id: "b1-l1-card1", name: "b1-l1-card1"),
]),
List(id: "board-1-list-2", name: "board-1-list-2", cards: [
Card(id: "b1-l2-card1", name: "b1-l2-card1"),
Card(id: "b1-l2-card2", name: "b1-l2-card2"),
])
])
completion(self.board)
} else {
self.board = Board(id: "board-2", name: "board-2", prefs: BoardPrefs(), lists: [
List(id: "board-2-list-1", name: "board-2-list-1", cards: [
]),
List(id: "board-2-list-2", name: "board-2-list-2", cards: [
Card(id: "b2-l2-card1", name: "b2-l2-card1"),
])
])
completion(self.board)
}
}
}
ContentView.swift
struct ContentView: View {
#EnvironmentObject var trelloApi: TrelloApi;
var body: some View {
HStack {
VStack {
Text("Switch Board")
Button(action: {
trelloApi.getBoard(id: "board-1")
}) {
Text("board 1")
}
Button(action: {
trelloApi.getBoard(id: "board-2")
}) {
Text("board 2")
}
}
VStack {
ScrollView([.horizontal]) {
ScrollView([.vertical]) {
VStack(){
HStack(alignment: .top) {
ForEach($trelloApi.board.lists) { list in
TrelloListView(list: list)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
}
}.onAppear {
trelloApi.getBoard(id: "board-1")
}
.frame(minWidth: 900, minHeight: 600, alignment: .top)
}
}
TrelloListView.swift
struct TrelloListView: View {
#EnvironmentObject var trelloApi: TrelloApi;
#Binding var list: List;
var body: some View {
VStack() {
Text(self.list.name)
Divider()
SwiftUI.List(self.$list.cards, id: \.id) { card in
CardView(card: card)
}
.listStyle(.plain)
.frame(minHeight: 200)
}
.padding(4)
.cornerRadius(8)
.frame(minWidth: 200)
}
}
CardView.swift
struct CardView: View {
#EnvironmentObject var trelloApi: TrelloApi;
#Binding var card: Card;
var body: some View {
VStack(alignment: .leading) {
HStack {
VStack(alignment: .leading, spacing: 0) {
Text(card.name)
.bold()
.font(.system(size: 14))
.multilineTextAlignment(.leading)
.lineLimit(1)
.foregroundColor(.white)
Text(card.desc)
.lineLimit(1)
.foregroundColor(.secondary)
}.padding()
Spacer()
}
}
.frame(alignment: .leading)
.onReceive(Just(card)) { newCard in
// CRASH LOCATION: "Index out of range" for self.card.labels
if self.card.labels != newCard.labels {
print("(check if card color should change based on labels)")
}
}
.cornerRadius(4)
}
}
I've highlighted the crash location with a comment. I don't pass any indexes in the ForEach or List and I am overwriting the entire trelloApi.board object, so I am not sure why I am getting this error.
I've tried using ForEach inside of the SwiftUI.List instead, but that also doesn't change anything.
The minimal reproducible code can also be found on my GitHub repo: https://github.com/Rukenshia/trello/tree/troubleshooting-board-switch-crash/trello
The exact issue is hard to track down, but here are some observations and recommandations.
The .onReceive() modifier you are using looks suspicious because you initialize the publisher yourself inline in the function call. You generally use .onReceive() to react to events published from publishers set up by another piece of code.
Moreover, you are using this .onReceive() to react to changes in a #Binding property, which is redundant since by definition a #Binding already triggers view updates when its value changes.
EDIT
This seems to be the issue that causes the crash in your app. Changing the .onReceive() to .onChange() seems to solve the problem:
.onChange(of: card) { newCard in
if self.card.labels != newCard.labels {
print("(check if card color should change based on labels)")
}
}
You also seem to duplicate some state:
.onReceive(Just(card)) { newCard in
self.due = newCard.dueDate
}
Here, you duplicated the due date, there is one copy in self.due and another copy in self.card.dueDate. In SwiftUI there should only be one source of truth and for you it would be the card property. You duplicated the state in the init: self.due = card.wrappedValue.dueDate. Accessing the .wrappedValue of a #Binding/State is a code smell and the sign that you are doing something wrong.
Lastly, ou use an anti-pattern which can be dangerous:
struct CardView: View {
#State private var isHovering: Bool
func init(isHovering: String) {
self._isHovering = State(initialValue: false)
}
var body: some View {
...
}
}
You should avoid initializing a #State property wrapper yourself in the view's init. A #State property must be initililized inline:
struct CardView: View {
#State private var isHovering: Bool = false
var body: some View {
...
}
}
If for some reason you have to customize the value of a #State property, you could use the .onAppear() or the newer .task() view modifier to change its value after the view creation:
struct CardView: View {
#State private var isHovering: Bool = false
var body: some View {
SomeView()
.onAppear {
isHovering = Bool.random()
}
}
}
As a general advice you should break up your views into smaller pieces. When a view depends on many #State properties and has lots of .onChange() or .onReceive() it is usually an indication that it is time to move the whole logic inside and ObservableObject or refactor into smaller components.

Dynamically sized #State var

I'm loading data into a struct from JSON. With this data, a new structure (itemStructures) is created and filled for use in a SwiftUI View. In this View I have a #State var which I manually initialise to have enough space to hold all items. This state var holds all parameters which are nested within the items, hence the nested array.
As long as this #State var has enough empty spaces everything works fine. But my question is, how do I modify this #State programmatically for when the number of items increases er decreases with the loading of a new JSON? I could make it really large but I'd rather have it the exact size after each load.
//Structs used in this example
struct MainViewState {
var itemStructures: [ItemStructure]
}
struct ItemStructure: Identifiable, Hashable {
var id: String {name}
var name: String
var parameters: [Parameter]
}
struct Parameter: Identifiable, Hashable {
var id: String {name}
var name: String
var value: Double
var range: [Double]
}
struct ContentView: View {
//In this model json is loaded, this seemed out of scope for this question to include this
#ObservedObject var viewModel: MainViewModel
//This is the #State var which should be dynamically allocated according to the content size of "itemStructures"
//For now 3 items with 10 parameters each are enough
#State var parametersPerItem: [[Float]] = [
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0]
]
init(viewModel: MainViewModel) {
self.viewModel = viewModel
}
var body: some View {
let itemStructures = viewModel.mainState.itemStructures
ForEach( Array(itemStructures.enumerated()), id: \.element ) { index, item in
Text(item.name)
ForEach( Array(item.parameters.enumerated()), id: \.element ) { i, parameter in
Text(parameter.name)
SliderView(
label: parameter.name,
value: Binding(
get: { self.parametersPerItem[index][i] },
set: { (newVal) in
self.parametersPerItem[index][i] = newVal
//Function to send slider values and ranges to real time processing
//doStuffWithRangesAndValues()
}
),
range: parameter.range,
showsLabel: false
).onAppear {
//Set initial value slider
parametersPerItem[index][i] = Float(parameter.value)
}
}
}
}
}
struct SliderView: View {
var label: String
#Binding var value: Float
var range: [Double]
var showsLabel: Bool
init(label: String, value: Binding<Float>, range: [Double], showsLabel: Bool = true) {
self.label = label
_value = value
self.range = range
self.showsLabel = showsLabel
}
var body: some View {
GeometryReader { geometry in
ZStack{
if showsLabel { Text(label) }
HStack {
Slider(value: $value)
.foregroundColor(.accentColor)
.frame(width: geometry.size.width * 0.8)
//In the real app range calculations are done here
let valueInRange = value
Text("\(valueInRange, specifier: range[1] >= 1000 ? "%.0f" : "%.2f")")
.foregroundColor(.white)
.font(.subheadline)
.frame(width: geometry.size.width * 0.2)
}
}
}
.frame(height: 40.0)
}
}
If you are looking for a solution where you want to initialise the array after the json has been loaded you could add a computed property in an extension to the main/root json model and use it to give the #State property an initial value.
extension MainViewState {
var parametersPerItem: [[Float]] {
var array: [[Float]] = []
if let max = itemStructures.map(\.parameters.count).max(by: { $0 < $1 }) {
for _ in itemStructures {
array.append(Array(repeating: 0.0, count: max))
}
}
return array
}
}

SwiftUI: How to update element in ForEach without necessity to update all elements?

Imagine that you have some parent view that generate some number of child views:
struct CustomParent: View {
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index)
}
}
}
}
struct CustomChild: View {
#State var index: Int
#State private var text: String = ""
var body: some View {
Button(action: {
// Here should be some update of background/text/opacity or whatever.
// So how can I update background/text/opacity or whatever for button with index for example 3 from button with index for example 1?
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Question is included in the code as comment.
Thanks!
UPDATE:
First of all really thanks for all of your answers, but now imagine that you use mentioned advanced approach.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
If I use let _ = Self._printChanges() method in CustomChildView, to catch UI updates/changes, it'll print that every element in ForEach was updated/changed on button action.
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
let _ = Self._printChanges() // This have been added to code
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}
And now imagine that you have for example 1000 custom elements which have some background, opacity, shadow, texts, fonts and so on. Now I change text in any of the elements.
Based on log from let _ = Self._printChanges() method, it goes through all elements, and all elements are updated/changed what can cause delay.
Q1: Why did update/change all elements, if I change text in only one element?
Q2: How can I prevent update/change all elements, if I change only one?
Q3: How to update element in ForEach without necessity to update all elements?
Simpler Approach:
Although child views cannot access things that the host views have, it's possible to declare the child states in the host view and pass that state as a binding variable to the child view. In the code below, I have passed the childTexts variable to the child view, and (for your convenience) initialized the text so that it binds to the original element in the array (so that your onAppear works properly). Every change performed on the text and childTexts variable inside the child view reflects on the host view.
I strongly suggest not to do this though, as more elegant approaches exist.
struct CustomParent: View {
#State var childTexts = [String](repeating: "", count: 10)
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, childTexts: $childTexts)
}
}
}
}
struct CustomChild: View {
let index: Int
#Binding private var text: String
#Binding private var childTexts: [String]
init(index: Int, childTexts: Binding<[String]>) {
self.index = index
self._childTexts = childTexts
self._text = childTexts[index]
}
var body: some View {
Button(action: {
//button behaviors goes here
//for example
childTexts[index + 1] = "A"
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Advanced Approach:
By using the Combine framework, all your logics can be moved into an ObservableObject view model. This is much better as the button logic is no longer inside the view. In simplest terms, the #Published variable in the ObservableObject will publish a change when it senses its own mutation, while the #StateObjectand the #ObservedObject will listen and recalculate the view for you.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}

Swift ForEach with non constant range View Refresh

I know it's a simple question, but I haven't found an answer. I want to understand the underlying concept.
I'm trying to update a ForEach with a non constant range, the closing parameter is a variable that is assigned to a button.
The variable is assigned with a #State so it's supposed to refresh the view. Somehow it's not working.
import SwiftUI
struct ContentView: View {
#State private var numberOfTimes = 5
let timesPicker = [2,5,10,12,20]
#State private var tableToPractice = 2
enum answerState {
case unanswered
case wrong
case right
}
func listRange(){
}
var body: some View {
NavigationView{
HStack{
VStack{
Form{
Section {
Picker("Tip percentage", selection: $numberOfTimes) {
ForEach(timesPicker, id: \.self) {
Text($0, format: .number)
}
}
.pickerStyle(.segmented)
} header: {
Text("How many times do you want to practice?")
}
Section{
Stepper("Table to practice: \(tableToPractice.formatted())", value: $tableToPractice, in: 2...16 )
}
Button("Start Now", action: listRange).buttonStyle(.bordered)
List{
ForEach(0..<numberOfTimes){
Text("Dynamic row \($0)")
}
}
}.foregroundColor(.gray)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The problem is that the range is not identified. Lets make some rows
struct Row: Identifiable {
let id = UUID()
}
Then set up an array of identifiable items
#State private var numberOfTimes = 5
#State private var rows = Array(repeating: Row(), count: 5)
Now you can have a responsive list
List{
ForEach(rows) { row in
Text("Dynamic row")
}
}
Call the on change update to recreate the array
.onChange(of: numberOfTimes) { newValue in
rows = Array(repeating: Row(), count: newValue)
numberOfTimes = newValue
}
the onChange should be called on the Form.
This will make more sense when you have better view model data, see apple documentation for a more in depth example.
This is for lazy v stack, but the data model setup is what I'm thinking of
https://developer.apple.com/documentation/swiftui/grouping-data-with-lazy-stack-views

'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)