SwiftUI ObservedObject makes view pop on update - swift

Why is my view going back to the parent when the ObservedObject updates? I understand that the update to it redraws the view (and presumably also the ParentView) but frankly atm that I am looking at the ChildView I don't care about the look of the ParentView.
I would understand the behaviour if the displayed stuff was coming directly from the ParentView but it is tied to a class. Is this intended behaviour from Apple?
How am I supposed to manipulate Data in a View that got called by a NavigationLink without popping the view?
Why is this pop forced?
Is there a way to "hold it back"?
I've tried something like this:
SwiftUI: #ObservedObject redraws every view but it didn't help. It just made it worse as I tap remove items anymore.
I have a code construct like this:
Parent View:
struct ContentView: View {
#ObservedObject var data = Data()
var body: some View {
NavigationView {
VStack() {
ForEach(self.data.myArray, id: \.self) { subArray in
NavigationLink(destination: Child(data: self.data, mySubArray: subArray)) {
Text(subArray[0]).padding().background(Color.yellow)
}
}
}
}
}
}
Child:
struct Child: View {
#ObservedObject var data: Data
var mySubArray: Array<String>
var body: some View {
ForEach(self.mySubArray, id: \.self) { str in
Text(str).padding().background(Color.yellow)
.onTapGesture {
self.data.removeFromSubArray(index: self.data.myArray.firstIndex(of: self.mySubArray) ?? 0, remove: str)
}
}
}
}
And an ObservableObject:
class Data: ObservableObject {
#Published var myArray: Array<Array<String>> = []
init() {
self.myArray.append(["A", "B", "C"])
self.myArray.append(["D", "E", "F"])
self.myArray.append(["G", "H", "I"])
}
func removeFromSubArray(index: Int, remove: String) {
if self.myArray.count-1 >= index {
if let subIndex = self.myArray[index].firstIndex(of: remove) {
self.myArray[index].remove(at: subIndex)
}
}
}
}

Related

How to update one view from within another in SwiftUI

I am working on a SwiftUI view where I have it populates other subviews within the main view. My question is how can I call something in the main view from within a sub view?
Here is what the code looks like for my main view:
struct MovieList: View {
#ObservedObject var viewModel = MovieViewModel()
var body: some View {
VStack {
ScrollView(.vertical) {
VStack {
ForEach(self.viewModel.movie) { movie in
MovieView(movie: movie)
}
}
}
}
.navigationBarTitle("Movies")
.onAppear {
self.viewModel.fetchMovies() // Fetch all movies and cause entire view to refresh and populate movies
}
}
}
This main view populates a list of movies by adding multiple MovieView instances.
Here is some example code for the MovieView:
struct MovieView: View {
let movie: Movie
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(“Movie Title: \(movie.title)”)
}.padding([.top, .leading, .bottom])
Button("Do not show this movie") {
// Update user prefers to hide the movie.
// But also somehow from within here call viewModel.fetchMovies() in the other view to refresh the movies list
}
}
}
}
So for example, from within one of the MovieView views, how can I have fetchMovies() from within the main view called so that everything gets updated?
Essentially a list of items is being populated and I would like for any one of these to have the ability to refresh/ perform some action on the entire main view
In terms of updating the Movie itself, as was pointed out in the comments, you probably want to pass a Binding to it. See the changes to the ForEach.
In terms of calling fetchMovies again, you can either pass the entire ObservableObject to the child view or just pass a reference to the function you need (which I've shown below):
struct Movie : Identifiable {
var id = UUID()
var title : String
var isHidden: Bool
}
class MovieViewModel : ObservableObject {
#Published var movies = [Movie]()
func fetchMovies() {
//fetch
}
}
struct MovieList: View {
#ObservedObject var viewModel = MovieViewModel()
var body: some View {
VStack {
ScrollView(.vertical) {
VStack {
ForEach($viewModel.movies) { $movie in
MovieView(movie: $movie, fetchMovies: viewModel.fetchMovies)
}
}
}
}
.navigationBarTitle("Movies")
.onAppear {
self.viewModel.fetchMovies()
}
}
}
struct MovieView: View {
#Binding var movie: Movie
var fetchMovies : () -> Void
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Movie Title: \(movie.title)")
}.padding([.top, .leading, .bottom])
Button("Do not show this movie") {
movie.isHidden = true
fetchMovies()
}
}
}
}

How to update an element of an array in an Observable Object

Sorry if my question is silly, I am a beginner to programming. I have a Navigation Link to a detail view from a List produced from my view model's array. In the detail view, I want to be able to mutate one of the tapped-on element's properties, but I can't seem to figure out how to do this. I don't think I explained that very well, so here is the code.
// model
struct Activity: Identifiable {
var id = UUID()
var name: String
var completeDescription: String
var completions: Int = 0
}
// view model
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
}
// view
struct ActivityView: View {
#StateObject var viewModel = ActivityViewModel()
#State private var showingAddEditActivityView = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.activities, id: \.id) {
activity in
NavigationLink(destination: ActivityDetailView(activity: activity, viewModel: self.viewModel)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
}
}
.navigationBarItems(trailing: Button("Add new"){
self.showingAddEditActivityView.toggle()
})
.navigationTitle(Text("Activity List"))
}
.sheet(isPresented: $showingAddEditActivityView) {
AddEditActivityView(copyViewModel: self.viewModel)
}
}
}
// detail view
struct ActivityDetailView: View {
#State var activity: Activity
#ObservedObject var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
var tempActivity = viewModel.activities.first{ activity in activity.id == self.activity.id
}!
tempActivity.completions += 1
}
}
// Add new activity view (doesn't have anything to do with question)
struct AddEditActivityView: View {
#ObservedObject var copyViewModel : ActivityViewModel
#State private var activityName: String = ""
#State private var description: String = ""
var body: some View {
VStack {
TextField("Enter an activity", text: $activityName)
TextField("Enter an activity description", text: $description)
Button("Save"){
// I want this to be outside of my view
saveActivity()
}
}
}
func saveActivity() {
copyViewModel.activities.append(Activity(name: self.activityName, completeDescription: self.description))
print(copyViewModel.activities)
}
}
In the detail view, I am trying to update the completion count of that specific activity, and have it update my view model. The method I tried above probably doesn't make sense and obviously doesn't work. I've just left it to show what I tried.
Thanks for any assistance or insight.
The problem is here:
struct ActivityDetailView: View {
#State var activity: Activity
...
This needs to be a #Binding in order for changes to be reflected back in the parent view. There's also no need to pass in the entire viewModel in - once you have the #Binding, you can get rid of it.
// detail view
struct ActivityDetailView: View {
#Binding var activity: Activity /// here!
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
}
Text("\(activity.completeDescription)")
}
}
}
But how do you get the Binding? If you're using iOS 15, you can directly loop over $viewModel.activities:
/// here!
ForEach($viewModel.activities, id: \.id) { $activity in
NavigationLink(destination: ActivityDetailView(activity: $activity)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
And for iOS 14 or below, you'll need to loop over indices instead. But it works.
/// from https://stackoverflow.com/a/66944424/14351818
ForEach(Array(zip(viewModel.activities.indices, viewModel.activities)), id: \.1.id) { (index, activity) in
NavigationLink(destination: ActivityDetailView(activity: $viewModel.activities[index])) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
You are changing and increment the value of tempActivity so it will not affect the main array or data source.
You can add one update function inside the view model and call from view.
The view model is responsible for this updation.
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
func updateCompletionCount(for id: UUID) {
if let index = activities.firstIndex(where: {$0.id == id}) {
self.activities[index].completions += 1
}
}
}
struct ActivityDetailView: View {
var activity: Activity
var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
self.viewModel.updateCompletionCount(for: activity.id)
}
}
Not needed #State or #ObservedObject for details view if don't have further action.

Why doesn't calling method of child view from parent view update the child view?

I'm trying to call a method of a child view which includes clearing some of its fields. When the method is called from a parent view, nothing happens. However, calling the method from the child view will clear its field. Here is some example code:
struct ChildView: View {
#State var response = ""
var body: some View {
TextField("", text: $response)
}
func clear() {
self.response = ""
}
}
struct ParentView: View {
private var child = ChildView()
var body: some View {
HStack {
self.child
Button(action: {
self.child.clear()
}) {
Text("Clear")
}
}
}
}
Can someone tell me why this happens and how to fix it/work around it? I can't directly access the child view's response because there are too many fields in my actual code and that would clutter it up too much.
SwiftUI view is not a reference-type, you cannot create it once, store in var, and then access it - SwiftUI view is a struct, value type, so storing it like did you work with copies it values, ie
struct ParentView: View {
private var child = ChildView() // << original value
var body: some View {
HStack {
self.child // created copy 1
Button(action: {
self.child.clear() // created copy 2
}) {
Here is a correct SwiftUI approach to construct parent/child view - everything about child view should be inside child view or injected in it via init arguments:
struct ChildView: View {
#State private var response = ""
var body: some View {
HStack {
TextField("", text: $response)
Button(action: {
self.clear()
}) {
Text("Clear")
}
}
}
func clear() {
self.response = ""
}
}
struct ParentView: View {
var body: some View {
ChildView()
}
}
Try using #Binding instead of #State. Bindings are a way of communicating state changes down to children.
Think of it this way: #State variables are used for View specific state. They are usually made private for this reason. If you need to communicate anything down, then #Binding is the way to do it.
struct ChildView: View {
#Binding var response: String
var body: some View {
TextField("", text: $response)
}
}
struct ParentView: View {
#State private var response = ""
var body: some View {
HStack {
ChildView(response: $response)
Button(action: {
self.clear()
}) {
Text("Clear")
}
}
}
private func clear() {
self.response = ""
}
}

SwiftUI out of index when deleting an array element in ForEach

I looked through different questions here, but unfortunately I couldn't find an answer. This is my code:
SceneDelegate.swift
...
let contentView = ContentView(elementHolder: ElementHolder(elements: ["abc", "cde", "efg"]))
...
window.rootViewController = UIHostingController(rootView: contentView)
ContentView.swift
class ElementHolder: ObservableObject {
#Published var elements: [String]
init(elements: [String]) {
self.elements = elements
}
}
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(elementHolder: self.elementHolder, index: index)
}
}
}
}
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("...", text: self.$elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
When pressing on the delete button, the app is crashing with a Index out of bounds error.
There are two strange things, the app is running when
1) you remove the VStack and just put the ForEach into the body of the ContentView.swift or
2) you put the code of the SecondView directly to the ForEach
Just one thing: I really need to have the ObservableObject, this code is just a simplification of another code.
UPDATE
I updated my code and changed Text to a TextField, because I cannot pass just a string, I need a connection in both directions.
The issue arises from the order in which updates are performed when clicking the delete button.
On button press, the following will happen:
The elements property of the element holder is changed
This sends a notification through the objectWillChange publisher that is part of the ElementHolder and that is declared by the ObservableObject protocol.
The views, that are subscribed to this publisher receive a message and will update their content.
The SecondView receives the notification and updates its view by executing the body getter.
The ContentView receives the notification and updates its view by executing the body getter.
To have the code not crash, 3.1 would have to be executed after 3.2. Though it is (to my knowledge) not possible to control this order.
The most elegant solution would be to create an onDelete closure in the SecondView, which would be passed as an argument.
This would also solve the architectural anti-pattern that the element view has access to all elements, not only the one it is showing.
Integrating all of this would result in the following code:
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(
element: self.elementHolder.elements[index],
onDelete: {self.elementHolder.elements.remove(at: index)}
)
}
}
}
}
struct SecondView: View {
var element: String
var onDelete: () -> ()
var body: some View {
HStack {
Text(element)
Button(action: onDelete) {
Text("delete")
}
}
}
}
With this, it would even be possible to remove ElementHolder and just have a #State var elements: [String] variable.
Here is possible solution - make body of SecondView undependable of ObservableObject.
Tested with Xcode 11.4 / iOS 13.4 - no crash
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
let value: String
init(elementHolder: ElementHolder, index: Int) {
self.elementHolder = elementHolder
self.index = index
self.value = elementHolder.elements[index]
}
var body: some View {
HStack {
Text(value) // not refreshed on delete
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Another possible solution is do not observe ElementHolder in SecondView... for presenting and deleting it is not needed - also no crash
struct SecondView: View {
var elementHolder: ElementHolder // just reference
var index: Int
var body: some View {
HStack {
Text(self.elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Update: variant of SecondView for text field (only changed is text field itself)
struct SecondViewA: View {
var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("", text: Binding(get: { self.elementHolder.elements[self.index] },
set: { self.elementHolder.elements[self.index] = $0 } ))
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}

Pushing a new List from a NavigationLink within a List freezes the simulator/canvas (SwiftUI)

When building a List View that can push a secondary List of items, the XCode simulator and SwiftUI canvas freezes without throwing an error. The code below describes the view hierarchy I am using to recreate this issue:
class Listable: Identifiable {
var id: UUID = UUID()
var name: String = "Name"
var title: String = "Title"
var description: String = "This is a description"
}
struct ContentView: View {
var items: [Listable]
var body: some View {
NavigationView {
List(items) { item in
ListCell(item: item)
}
.navigationBarTitle(self.items[0].title)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(items: testList)
}
}
struct SecondaryListView: View {
var items: [Listable]
var body: some View {
List(items) { item in
ListCell(item: item)
}
.navigationBarTitle(self.items[0].title)
}
}
struct ListCell: View {
var item: Listable
var body: some View {
NavigationLink(destination: SecondaryListView(items: testSecondaryList)) {
Image(systemName: "photo")
VStack(alignment: .leading) {
Text(item.name)
.font(.title)
.fontWeight(.light)
Text(item.description)
}
}
}
}
let testList = [
Listable(),
Listable(),
Listable()
]
let testSecondaryList = [
Listable(),
Listable(),
Listable(),
Listable(),
Listable(),
Listable()
]
Note: If I replace the List object within SecondaryListView with a ForEach (as seen below), the code compiles and runs with no issue, and I can navigate as far down the stack as I'd like.
struct SecondaryListView: View {
var items: [Listable]
var body: some View {
ForEach(items) { item in
ListCell(item: item)
}
.navigationBarTitle(self.items[0].title)
}
}
Is pushing a List View from within a List View not allowed or is this a bug? It appears to cause a memory leak possibly - CPU on my main thread hits 99%.
The root cause ended up being that I was modifying the UITableView.appearance().backgroundView in the SceneDelegate. See below code:
SceneDelegate.swift:
UITableView.appearance().backgroundView = UIImageView(image: UIImage(named:"Background"), highlightedImage: nil)
My goal was to have a background image across all UITableViews within the app (Lists in SwiftUI still leverage UIKit components). The workaround I settled on was instead setting a background color with a UIColor init'd with a pattern image, as shown below:
UITableView.appearance().backgroundColor = UIColor(patternImage: UIImage(named:"Background") ?? UIImage())