ForEach not properly updating with dynamic content SwiftUI - swift

Sorry to make this post so long, but in hindsight I should have shown you the simpler instance of the issue so you could better understand what the problem is. I am assuming the same issue with ForEach is at the root cause of both of these bugs, but I could be wrong. The second instance is still included to give you context, but the first intance should be all you need to fully understand the issue.
First Instance:
Here is a video of the issue: https://imgur.com/a/EIg9TSm. As you can see, there are 4 Time Codes, 2 of which are favorite and 2 are not favorites (shown by the yellow star). Additionally, there is text at the top that represents the array of Time Codes being displayed just as a list of favorite (F) or not favorite (N). I click on the last Time Code (Changing to favorite) and press the toggle to unfavorite it. When I hit save, the array of Time Codes is updated, yet as you see, this is not represented in the List. However, you see that the Text of the reduced array immediately updates to FNFF, showing that it is properly updated as a favorite by the ObservedObject.
When I click back on the navigation and back to the page, the UI is properly updated and there are 3 yellow stars. This makes me assume that the problem is with ForEach, as the Text() shows the array is updated but the ForEach does not. Presumably, clicking out of the page reloads the ForEach, which is why it updates after exiting the page. EditCodeView() handles the saving of the TimeCodeVieModel in CoreData, and I am 99% certain that it works properly through my own testing and the fact that the ObservedObject updates as expected. I am pretty sure I am using the dynamic version of ForEach (since TimeCodeViewModel is Identifiable), so I don't know how to make the behavior update immediately after saving. Any help would be appreciated.
Here is the code for the view:
struct ListTimeCodeView: View {
#ObservedObject var timeCodeListVM: TimeCodeListViewModel
#State var presentEditTimeCode: Bool = false
#State var timeCodeEdit: TimeCodeViewModel?
init() {
self.timeCodeListVM = TimeCodeListViewModel()
}
var body: some View {
VStack {
HStack {
Text("TimeCodes Reduced by Favorite:")
Text("\(self.timeCodeListVM.timeCodes.reduce(into: "") {$0 += $1.isFavorite ? "F" : "N"})")
}
List {
ForEach(self.timeCodeListVM.timeCodes) { timeCode in
TimeCodeDetailsCell(fullName: timeCode.fullName, abbreviation: timeCode.abbreviation, color: timeCode.color, isFavorite: timeCode.isFavorite, presentEditTimeCode: $presentEditTimeCode)
.contentShape(Rectangle())
.onTapGesture {
timeCodeEdit = timeCode
}
.sheet(item: $timeCodeEdit, onDismiss: didDismiss) { detail in
EditCodeView(timeCodeEdit: detail)
}
}
}
}
}
}
Here is the code for the View Models (shouldn't be relevant to the problem, but included for understanding):
class TimeCodeListViewModel: ObservableObject {
#Published var timeCodes = [TimeCodeViewModel]()
init() {
fetchAllTimeCodes()
}
func fetchAllTimeCodes() {
self.timeCodes = CoreDataManager.shared.getAllTimeCodes().map(TimeCodeViewModel.init)
}
}
class TimeCodeViewModel: Identifiable {
var id: String = ""
var fullName = ""
var abbreviation = ""
var color = ""
var isFavorite = false
var tags = ""
init(timeCode: TimeCode) {
self.id = timeCode.id!.uuidString
self.fullName = timeCode.fullName!
self.abbreviation = timeCode.abbreviation!
self.color = timeCode.color!
self.isFavorite = timeCode.isFavorite
self.tags = timeCode.tags!
}
}
Second Instance:
EDIT: I realize it may be difficult to understand what the code is doing, so I have included a gif demoing the problem (unfortunately I am not high enough reputation for it to be shown automatically). As you can see, I select the cells I want to change, then press the button to assign that TimeCode to it. The array of TimeCodeCellViewModels changes in the background, but you don't actually see that change until I press the home button and then reopen the app, which triggers a refresh of ForEach. Gif of issue. There is also this video if the GIF is too fast: https://imgur.com/a/Y5xtLJ3
I am trying to display a grid view using a VStack of HStacks, and am running into an issue where the ForEach I am using to display the content is not refreshing when the array being passed in changes. I know the array itself is changing because if I reduce it to a string and display the contents with Text(), it properly updates as soon as a change is made. But, the ForEach loop only updates if I close and reopen the app, forcing the ForEach to reload. I know that there is a special version of ForEach that is specifically designed for dynamic content, but I am pretty sure I am using this version since I pass in '''id: .self'''. Here is the main code snippet:
var hoursTimeCode: [[TimeCodeCellViewModel]] = []
// initialize hoursTimeCode
VStack(spacing: 3) {
ForEach(self.hoursTimeCode, id: \.self) {row in
HStack(spacing: 3){
HourTimeCodeCell(date: row[0].date) // cell view for hour
.frame(minWidth: 50)
ForEach(row.indices, id: \.self) {cell in
// TimeCodeBlockCell displays minutes normally. If it is selected, and a button is pressed, it is assigned a TimeCode which it will then display
TimeCodeBlockCell(timeCodeCellVM: row[cell], selectedArray: $selectedTimeCodeCells)
.frame(maxWidth: .infinity)
.aspectRatio(1.0, contentMode: .fill)
}
}
}
}
I'm pretty sure it doesn't change anything, but I did have to define a custom hash function for the TimeCodeCellViewModel, which might change the behavior of the ForEach (the attributes being changed are included in the hash function). However, I have noticed the same ForEach behavior in another part of my project that uses a different view model, so I highly doubt this is the issue.
class TimeCodeCellViewModel:Identifiable, Hashable {
static func == (lhs: TimeCodeCellViewModel, rhs: TimeCodeCellViewModel) -> Bool {
if lhs.id == rhs.id {
return true
}
else {
return false
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(isSet)
hasher.combine(timeCode)
hasher.combine(date)
}
var id: String = ""
var date = Date()
var isSet = false
var timeCode: TimeCode
var frame: CGRect = .zero
init(timeCodeCell: TimeCodeCell) {
self.id = timeCodeCell.id!.uuidString
self.date = timeCodeCell.date!
self.isSet = timeCodeCell.isSet
self.timeCode = timeCodeCell.toTimeCode!
}
}

Here is a snippet of what you need to make the code work.
See the comments for some basics of why
struct EditCodeView:View{
#EnvironmentObject var timeCodeListVM: TimeCodeListViewModel
//This will observe changes to the view model
#ObservedObject var timeCodeViewModel: TimeCodeViewModel
var body: some View{
EditTimeCodeView(timeCode: timeCodeViewModel.timeCode)
.onDisappear(perform: {
//*********TO SEE CHANGES WHEN YOU EDIT
//uncomment this line***********
//_ = timeCodeListVM.update(timeCodeVM: timeCodeViewModel)
})
}
}
struct EditTimeCodeView: View{
//This will observe changes to the core data entity
#ObservedObject var timeCode: TimeCode
var body: some View{
Form{
TextField("name", text: $timeCode.fullName.bound)
TextField("appreviation", text: $timeCode.abbreviation.bound)
Toggle("favorite", isOn: $timeCode.isFavorite)
}
}
}
class TimeCodeListViewModel: ObservableObject {
//Replacing this whole thing with a #FetchRequest would be way more efficient than these extra view models
//IF you dont want to use #FetchRequest the only other way to observe the persistent store for changes is with NSFetchedResultsController
//https://stackoverflow.com/questions/67526427/swift-fetchrequest-custom-sorting-function/67527134#67527134
//This array will not see changes to the variables of the ObservableObjects
#Published var timeCodeVMs = [TimeCodeViewModel]()
private var persistenceManager = TimeCodePersistenceManager()
init() {
fetchAllTimeCodes()
}
func fetchAllTimeCodes() {
//This method does not observe for new and or deleted timecodes. It is a one time thing
self.timeCodeVMs = persistenceManager.retrieveObjects(sortDescriptors: nil, predicate: nil).map({
//Pass the whole object there isnt a point to just passing the variables
//But the way you had it broke the connection
TimeCodeViewModel(timeCode: $0)
})
}
func addNew() -> TimeCodeViewModel{
let item = TimeCodeViewModel(timeCode: persistenceManager.addSample())
timeCodeVMs.append(item)
//will refresh view because there is a change in count
return item
}
///Call this to save changes
func update(timeCodeVM: TimeCodeViewModel) -> Bool{
let result = persistenceManager.updateObject(object: timeCodeVM.timeCode)
//You have to call this to see changes at the list level
objectWillChange.send()
return result
}
}
//DO you have special code that you aren't including? If not what is the point of this view model?
class TimeCodeViewModel: Identifiable, ObservableObject {
//Simplify this
//This is a CoreData object therefore an ObservableObject it needs an #ObservedObject in a View so changes can be seem
#Published var timeCode: TimeCode
init(timeCode: TimeCode) {
self.timeCode = timeCode
}
}

Your first ForEach probably cannot check if the identity of Array<TimeCodeCellViewModel> has changed.
Perhaps you want to use a separate struct which holds internally an array of TimeCodeCellViewModel and conforms to Identifiable, effectively implementing such protocol.
stuct TCCViewModels: Identifiable {
let models: Array<TimeCodeCellViewModel>
var id: Int {
models.hashValue
}
}
You might as well make this generic too, so it can be reused for different view models in your app:
struct ViewModelsContainer<V: Identifiable> where V.ID: Hashable {
let viewModels: Array<V>
let id: Int
init(viewModels: Array<V>) {
self.viewModels = viewModels
var hasher = Hasher()
hasher.combine(viewModels.count)
viewModels.forEach { hasher.combine($0.id) }
self.id = hasher.finalize
}
}

Related

SwiftUI: Set a Published value in an ObservableObject from the UI (Picker, etc.)

Update:
This question is already solved (see responses below). The correct way to do this is to get your Binding by projecting the
ObservableObject For example, $options.refreshRate.
TLDR version:
How do I get a SwiftUI Picker (or other API that relies on a local Binding) to immediately update my ObservedObject/EnvironmentObject. Here is more context...
The scenario:
Here is something I consistently need to do in every SwiftUI app I create...
I always make some class that stores any user preference (let's call this class Options and I make it an ObservableObject.
Any setting that needs to be consumed is marked with #Published
Any view that consumes this brings it in as a #ObservedObject or #EnvironmentObject and subscribes to changes.
This all works quite nicely. The trouble I always face is how to set this from the UI. From the UI, here is usually what I'm doing (and this should all sound quite normal):
I have some SwiftUI view like OptionsPanel that drives the Options class above and allows the user to choose their options.
Let's say we have some option defined by an enum:
enum RefreshRate {
case low, medium, high
}
Naturally, I'd choose a Picker in SwiftUI to set this... and the Picker API requires that my selection param be a Binding. This is where I find the issue...
The issue:
To make the Picker work, I usually have some local Binding that is used for this purpose. But, ultimately, I don't care about that local value. What I care about is immediately and instantaneously broadcasting that new value to the rest of the app. The moment I select a new refresh rate, I'd like immediately know that instant about the change. The ObservableObject (the Options class) object does this quite nicely. But, I'm just updating a local Binding. What I need to figure out is how to immediately translate the Picker's state to the ObservableObject every time it's changed.
I have a solution that works... but I don't like it. Here is my non-ideal solution:
The non-ideal solution:
The first part of the solution is quite actually fine, but runs into a snag...
Within my SwiftUI view, rather than do the simplest way to set a Binding with #State I can use an alternate initializer...
// Rather than this...
#ObservedObject var options: Options
#State var refreshRate: RefreshRate = .medium
// Do this...
#ObservedObject var options: Options
var refreshRate: Binding<RefreshRate>(
get: { self.options.refreshRate },
set: { self.options.refreshRate = $0 }
)
So far, this is great (in theory)! Now, my local Binding is directly linked to the ObservableObject. All changes to the Picker are immediately broadcast to the entire app.
But this doesn't actually work. And this is where I have to do something very messy and non-ideal to get it to work.
The code above produces the following error:
Cannot use instance member 'options' within property initializer; property initializers run before 'self' is available
Here my my (bad) workaround. It works, but it's awful...
The Options class provides a shared instance as a static property. So, in my options panel view, I do this:
#ObservedObject var options: Options = .shared // <-- This is still needed to tell SwiftUI to listen for updates
var refreshRate: Binding<RefreshRate>(
get: { Options.shared.refreshRate },
set: { Options.shared.refreshRate = $0 }
)
In practice, this actually kinda works in this case. I don't really need to have multiple instances... just that one. So, as long as I always reference that shared instance, everything works. But it doesn't feel well architected.
So... does anyone have a better solution? This seems like a scenario EVERY app on the face of the planet has to tackle, so it seems like someone must have a better way.
(I am aware some use an .onDisapear to sync local state to the ObservedObject but this isn't ideal either. This is non-ideal because I value having immediate updates for the rest of the app.)
The good news is you're trying way, way, way too hard.
The ObservedObject property wrapper can create this Binding for you. All you need to say is $options.refreshRate.
Here's a test playground for you to try out:
import SwiftUI
enum RefreshRate {
case low, medium, high
}
class Options: ObservableObject {
#Published var refreshRate = RefreshRate.medium
}
struct RefreshRateEditor: View {
#ObservedObject var options: Options
var body: some View {
// vvvvvvvvvvvvvvvvvvvv
Picker("Refresh Rate", selection: $options.refreshRate) {
// ^^^^^^^^^^^^^^^^^^^^
Text("Low").tag(RefreshRate.low)
Text("Medium").tag(RefreshRate.medium)
Text("High").tag(RefreshRate.high)
}
.pickerStyle(.segmented)
}
}
struct ContentView: View {
#StateObject var options = Options()
var body: some View {
VStack {
RefreshRateEditor(options: options)
Text("Refresh rate: \(options.refreshRate)" as String)
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())
It's also worth noting that if you want to create a custom Binding, the code you wrote almost works. Just change it to be a computed property instead of a stored property:
var refreshRate: Binding<RefreshRate> {
.init(
get: { self.options.refreshRate },
set: { self.options.refreshRate = $0 }
)
}
If I understand your question correctly, you want
to Set a Published value in an ObservableObject from the UI (Picker, etc.) in SwiftUI.
There are many ways to do that, I suggest you use a ObservableObject class, and use it directly wherever you need a binding in a view, such as in a Picker.
The following example code shows one way of setting up your code to do that:
import Foundation
import SwiftUI
// declare your ObservableObject class
class Options: ObservableObject {
#Published var name = "Mickey"
}
struct ContentView: View {
#StateObject var optionModel = Options() // <-- initialise the model
let selectionSet = ["Mickey", "Mouse", "Goofy", "Donald"]
#State var showSheet = false
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.red)
Picker("names", selection: $optionModel.name) { // <-- use the model directly as a $binding
ForEach (selectionSet, id: \.self) { value in
Text(value).tag(value)
}
}
Button("Show other view") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
SheetView(optionModel: optionModel) // <-- pass the model to other view, see also #EnvironmentObject
}
}
}
struct SheetView: View {
#ObservedObject var optionModel: Options // <-- receive the model
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.green) // <-- show updated value
}
}
}
If you really want to have a "useless" intermediate local variable, then use this approach:
struct ContentView: View {
#StateObject var optionModel = Options() // <-- initialise the model
let selectionSet = ["Mickey", "Mouse", "Goofy", "Donald"]
#State var showSheet = false
#State var localVar = "" // <-- the local var
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.red)
Picker("names", selection: $localVar) { // <-- using the localVar
ForEach (selectionSet, id: \.self) { value in
Text(value).tag(value)
}
}
.onChange(of: localVar) { newValue in
optionModel.name = newValue // <-- update the model
}
Button("Show other view") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
SheetView(optionModel: optionModel) // <-- pass the model to other view, see also #EnvironmentObject
}
}
}

Swift & SwiftUI - Conditional global var

I want to make a global variable in Swift, so that its Data is accessible to any view that needs it. Eventually it will be a var so that I can mutate it, but while trying to get past this hurdle I'm just using it as let
I can do that by putting this as the top of a file (seemingly any file, Swift is weird):
let myData: [MyStruct] = load("myDataFile.json)
load() returns a JSONDecoder(). MyStruct is a :Hashable, Codable, Identifiable struct
That data is then available to any view that wants it, which is great. However, I want to be able to specify the file that is loaded based on a condition - I'm open to suggestions, but I've been using an #AppStorage variable to determine things when inside a View.
What I'd like to do, but can't, is do something like:
#AppStorage("appStorageVar") var appStorageVar: String = "Condition1"
if(appStorageVar == "Condition2") {
let myData: [MyStruct] = load("myDataFile2.json")
}
else {
let myData: [MyStruct] = load("myDataFile.json")
}
I can do this inside a View's body, but then it's only accessible to that View and then I have to repeat it constantly, which can't possibly the correct way to do it.
You could change just change the global in an onChange on the AppStorage variable. This is an answer to your question, but you have the problem that no view is going to be updating itself when the global changes.
var myData: [MyStruct] = load("myDataFile.json)
struct ContentView: View {
#AppStorage("appStorageVar") var appStorageVar: String = "Condition1"
var body: some View {
Button("Change value") {
appStorageVar = "Condition2"
}
.onChange(of: appStorageVar) { newValue in
myData = load(newValue == "Condition1" ? "myDataFile.json" : "myDataFile2.json")
}
}
}

SwiftUI / Combine subscribe to updates in multiple nested collections

I have a SummaryView with a Report as #State.
A Report is a protocol which includes some changes a user might want to make:
protocol Report {
var changeGroups: [ChangeGroup] { get set }
}
There are several kinds of reports; individual reports are implemented as a struct:
struct RealEstateReport: Report {
static let name = "Real Estate Report"
var changeGroups = [ChangeGroup]()
}
A ChangeGroup is a struct with (among other stuff) a human-readable summary and a handful of proposed changes:
struct ChangeGroup: Identifiable {
var summary: String
var proposedChanges = [ProposedChange]()
}
A ProposedChange is a class that represents one discrete change the app proposes to the user, which is enabled by default:
class ProposedChange: ObservableObject, Identifiable {
#Published var enabled = true
let summary: String
(In a detail view, enabled is bound to a Toggle so a user can flip each proposed change on and off.)
So a Report has many ChangeGroups which themselves have many ProposedChanges.
I'm trying to include some high level details on the SummaryView:
struct SummaryView: View {
#State var report: Report
var body: some View {
Text("Summary")
.foregroundColor(…) // ???
}
I want foregroundColor to be red, yellow, or green:
Red if enabled is false for all ProposedChanges in this Report
Green if enabled is true for all ProposedChanges in this Report
Yellow if enabled is mixed for different ProposedChanges in this Report
I've read a bit about Combine, and I think I need to create a new Combine subscription for each ChangeGroup, and map that to a new Combine subscription for each ProposedChange's enabled property, flatten the values when one changes, and check if they're all the same.
I'm a little lost on the exact syntax I'd use. And also it seems like structs don't publish changes in the same way (I guess since the structs are value vs. reference types).
How can I set the foregroundColor of the Text view based on the above logic?
Your issue is immediately solved if ProposedChange is a struct and not a class. Unless its instances have their own life cycle, then they are just holders of value, so should be semantically a struct.
The reason your issue is solved is because mutating a property of a struct mutates the struct, so SwiftUI knows to recompute the view, whereas with a class you need to subscribe to changes.
Assuming ProposedChange is a struct:
struct ProposedChange {
var enabled = true
var summary: String
}
the following should work:
struct SummaryView: View {
#State var report: Report
var body: some View {
Text("Summary")
.foregroundColor(summaryColor)
}
var summaryColor: Color {
let count = report.changeGroups.flatMap { $0.proposedChanges }
.map { ($0.enabled ? 1 : 0, 1) }
.reduce((0, 0), { ($0.0 + $1.0, $0.1 + $1.1) })
if count.0 == count.1 { return Color.green }
else if count.0 == 0 { return Color.red }
else { return Color.yellow }
}
}
I ended up mapping all the enabled flags to their publisher, combining them all using the CombineLatest operator, and then recalculating when the value changes:
class ViewModel: ObservableObject {
enum BoolState {
case allTrue, allFalse, mixed
}
#Published var boolState: BoolState?
private var report: Report
init(report: Report) {
self.report = report
report
.changeGroups // [ChangeGroup]
.map { $0.proposedChanges } // [[ProposedChange]]
.flatMap { $0 } // [ProposedChange]
.map { $0.$enabled } // [AnyPublisher<Bool, Never>]
.combineLatest() // AnyPublisher<[Bool], Never>
.map { Set($0) } // AnyPublisher<Set<Bool>, Never>
.map { boolSet -> BoolState in
switch boolSet {
case [false]:
return .allFalse
case [true]:
return .allTrue
default:
return .mixed
}
} // AnyPublisher<BoolState, Never>
.assign(to: &$boolState)
}
}
Note: .combineLatest() is not part of Combine but it's just an extension I wrote that iterates each pair of publishers in the array and calls them iteratively, like first.combineLatest(second).combineLatest(third) etc. If you need something more robust than this, it looks like the CombineExt project has a CombineLatestMany extension with several options.
At this point my view just does a #ObservedObject var viewModel: ViewModel and then uses viewModel.boolState in the body. Whenever any of the enabled flags change for any reason, the view updates successfully!

What's best practice for programmatic movement a NavigationView in SwiftUI

I'm working on an app that needs to open on the users last used view even if the app is completly killed by the user or ios.
As a result I'm holding last view used in UserDefaults and automatically moving the user through each view in the stack until they reach their destination.
The code on each view is as follows:
#Binding var redirectionID: Int
VStack() {
List {
NavigationLink(destination: testView(data: data, moc: moc), tag: data.id, selection:
$redirectionId) {
DataRow(data: data)
}
}
}.onAppear() {
redirectionID = userData.lastActiveView
}
Is there a better / standard way to achieve this? This works reasonably on iOS 14.* but doesn't work very well on iOS 13.* On iOS 13.* The redirection regularly doesnt reach its destination page and non of the preceeding views in the stack seem to be created. Pressing back etc results in a crash.
Any help / advice would be greatly appreciated.
This sounds like the perfect use of if SceneStorage
"You use SceneStorage when you need automatic state restoration of the value. SceneStorage works very similar to State, except its initial value is restored by the system if it was previously saved, and the value is· shared with other SceneStorage variables in the same scene."
#SceneStorage("ContentView.selectedProduct") private var selectedProduct: String?
#SceneStorage("DetailView.selectedTab") private var selectedTab = Tabs.detail
It is only available in iOS 14+ though so something manual would have to be implemented. Maybe something in CoreData. An object that would have variables for each important state variable. It would work like an ObservedObject ViewModel with persistence.
Also. you can try...
"An NSUserActivity object captures the app’s state at the current moment in time. For example, include information about the data the app is currently displaying. The system saves the provided object and returns it to the app the next time it launches. The sample creates a new NSUserActivity object when the user closes the app or the app enters the background."
Here is some sample code that summarizes how to bring it all together. It isn't a minimum reproducible example because it is a part of the larger project called "Restoring Your App's State with SwiftUI" from Apple. But it gives a pretty good picture on how to implement it.
struct ContentView: View {
// The data model for storing all the products.
#EnvironmentObject var productsModel: ProductsModel
// Used for detecting when this scene is backgrounded and isn't currently visible.
#Environment(\.scenePhase) private var scenePhase
// The currently selected product, if any.
#SceneStorage("ContentView.selectedProduct") private var selectedProduct: String?
let columns = Array(repeating: GridItem(.adaptive(minimum: 94, maximum: 120)), count: 3)
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(productsModel.products) { product in
NavigationLink(destination: DetailView(product: product, selectedProductID: $selectedProduct),
tag: product.id.uuidString,
selection: $selectedProduct) {
StackItemView(itemName: product.name, imageName: product.imageName)
}
.padding(8)
.buttonStyle(PlainButtonStyle())
.onDrag {
/** Register the product user activity as part of the drag provider which
will create a new scene when dropped to the left or right of the iPad screen.
*/
let userActivity = NSUserActivity(activityType: DetailView.productUserActivityType)
let localizedString = NSLocalizedString("DroppedProductTitle", comment: "Activity title with product name")
userActivity.title = String(format: localizedString, product.name)
userActivity.targetContentIdentifier = product.id.uuidString
try? userActivity.setTypedPayload(product)
return NSItemProvider(object: userActivity)
}
}
}
.padding()
}
.navigationTitle("ProductsTitle")
}
.navigationViewStyle(StackNavigationViewStyle())
.onContinueUserActivity(DetailView.productUserActivityType) { userActivity in
if let product = try? userActivity.typedPayload(Product.self) {
selectedProduct = product.id.uuidString
}
}
.onChange(of: scenePhase) { newScenePhase in
if newScenePhase == .background {
// Make sure to save any unsaved changes to the products model.
productsModel.save()
}
}
}
}

SwiftUI Add to ForEach array without causing the entire view to reload

if I have something like this:
struct ContentView: View {
var results = [Result(score: 8), Result(score: 5), Result(score: 10)]
var body: some View {
VStack {
ForEach(results, id: \.id) { result in
Text("Result: \(result.score)")
}
}
}
}
And then I have a button that appends sometihng to the results array, the entire ForEach loop will reload. This makes sense, but I'm wondering if there is some way to prevent this. The reason I'm asking is because I have a ForEach loop with a few items, each of which plays an animation. If another item is appended to the array, however, the new item appears at the top of the ForEach, but, since the entire view is reload, the other animations playing in the items stop.
Is there any way to prevent this? Like to add an item to a ForEach array, and have it appear, but not reload the entire ForEach loop?
I assume not, but I would wonder how to get around such an issue.
Create separate view for iterating ForEach content, then SwiftUI rendering engine will update container (by adding new item), but not refresh existed rows
ForEach(results, id: \.id) {
ResultCellView(result: $0)
}
and
struct ResultCellView: View {
let result: Result
var body: some View {
Text("Result: \(result.score)")
}
}
Note: I don't see your model, so there might be needed to confirm it to Hashable, Equatable.
In general not providing an id makes it impossible for the ForEach to know what changed (as it has no track of the items) and therefore does not re-render the view.
E.g.
struct ContentView: View {
#State var myData: Array<String> = ["first", "second"]
var body: some View {
VStack() {
ForEach(0..<self.myData.count) { item in
Text(self.myData[item])
}
Button(action: {
self.myData.append("third")
}){
Text("Add third")
}
}
}
}
This throws an console output (that you can ignore) where it tells you about what I just wrote above:
ForEach<Range<Int>, Int, Text> count (3) != its initial count (2).
`ForEach(_:content:)` should only be used for *constant* data.
Instead conform data to `Identifiable` or use `ForEach(_:id:content:)`
and provide an explicit `id`!
For your code try this:
Tested on iOS 13.5.
struct ContentView: View {
#State var results = [Result(score: 8), Result(score: 5), Result(score: 10)]
var body: some View {
VStack {
ForEach(0..<self.results.count) { item in
// please use some index test on production
Text("Result: \(self.results[item].score)")
}
Button(action: {
self.results.append(Result(score: 11))
}) {
Text("Add 11")
}
}
}
}
class Result {
var score: Int
init(score: Int) {
self.score = score
}
}
Please note that this is a "hacky" solution and ForEach was not intended to be used for such cases. (See the console output)