In SwiftUI, how can I avoid redrawing a parent view while updating shared state data from a child view to avoid Navigation issues? - swift

Since Xcode 12.5, I am seeing a lot of "Unable to present. Please file a bug." console logs in the Xcode console which I remember not seeing prior to 12.5.
This message is shown when I am using NavigationLink from a parent view to navigate to a child view, and if logic in the child view updates one of the states that parent view depends on.
Below is a sample pseudo code where a list of messages are shown in the parent view, message detail is shown in the child view from a list, and lastly, independent settings child view.
struct MessageListView: View {
...
#StateObject var messageList = MessageList()
var body: some View {
debugPrint("!!MessageListView has been redrawn!!")
return VStack {
NavigationLink(destination:SettingsView()){
Text("Go to settings")
}
ForEach(messageList.data.sorted(by: {$0.key < $1.key}), id:\.key) { k, m in
NavigationLink(destination:MessageView(message:...){
Text(m.text)
}
}
}
}
}
struct MessageView: View {
...
#ObservedObject var messageList : MessageList
#ObservedObject var message : Message
...
var body: some View {
Text(...)
.onAppear {
messageList.readAndIncrement(message.id) //<- This updates both this view & parent view.
}
}
}
class MessageList : ObservableObject {
#Published var data : [String:Message] = [:]
func readAndIncrement(id: String){
//Modify one of the message in dictionary.
}
}
So when user clicks on message and traverses the navigation like below,
MessageListView -> MessageView
As soon as MessageView appears on screen, it will increment message's "read count" due to at the logic in onAppear, which will update data in MessageListView at the same time.
As soon as that happens, it appears that the parent view, MessageListView which is observing MessageList objects gets updated, the two following things happen.
debugPrint will print message !!MessageListView has been redrawn!! (from source code above) on console, which proves that the parent view has been updated while Navigation is currently showing child view.
'Unable to present. Please file a bug.' console log gets shown on screen, probably because the update in the parent view "cannot" be presented on screen?
So SwiftUI seems to be throwing "Unable to present" error log when I am updating parent view's observed data while viewing & interacting in the child view, but I am unsure how I can properly fix to get rid of this error.
The reason that I am thinking this is not a bug, but my error is because of the following.
When user traverses into a completely different view, something like
MessageListView -> SettingsView
and if the following two conditions are met,
The child view (SettingsView) does not use or rely on any of the parent view's state objects.
Parent view's data model is updated by other means, such as from network updates/sync, periodic refresh, etc.
then the parent view (MessageListView) gets redrawn for its dependent model's every single update and the child view starts stacking itself like below.
At least, in the above case, Unable to present. Please file a bug. doesn't get shown in the console.
In the iOS 14.5 & Xcode 12.5 patch notes, the only comments related to NavigationLink is as follows, but that does not seem to be relevant to my case.
The destination of NavigationLink that only differs by local state now resets that state when switching between links as expected. (72117345)
So my question is... how can I properly manage states (or decouple them) in this case that won't cause funkiness in SwiftUI Navigation?
Is it against SwiftUI's paradigm to update parent view's state data while presenting child view on screen or is this simply a NavigationLink bug in Xcode 12.5?

I am answering my own question about the workaround that I found. The solution that worked for me is to use Class instead of Struct and update properties of the Class. This prevented the View, which is watching the #Published array/list, from getting updated.
Previously, I was using Struct for the Message type. What ended up happening is that any change to one of the properties of the struct/Message will trigger UI updates (which is expected), even if View is not directly watching the struct itself, but watching the list of structs (MessageList class's data property in my case.).
I converted Message from Struct to Class that implements ObservableObject and updates its properties. That prevented parent View, which dependent on the list of Messages from getting redrawn, when one of the list elements had to be updated.
In summary, when you assign #Published annotation to an array of Struct, list item addition/removal as well as any change to one of the properties of the struct item in the list will trigger dependent View updates in SwiftUI.
On the other hand, if you assign #Published annotation to an array of Class, only list item addition/removal will trigger SwiftUI update. Any change to one of the properties of the Class won't trigger dependent View updates in SwiftUI.

Related

Persist changes to core data item from SwiftUI view

I'm working on a SwiftUI app that is to display a number of entries from a Core Data database. Basically just about what the XCode template for such apps implements.
I've made two changes:
I put the code for the list item view into a separate file (BookmarkableItemView)
I added a property bookmarked to the item, which I called BookmarkableItem
The code in my view to show the list of items is now:
List(bookmarkableItems) { item in
BookmarkableItemView(item: item)
}
In BookmarkableItemView, the item property is an #ObservedObject.
Now, my BookmarkableItemView also contains a button that should toggle the bookmarked property on the item that the view represents. All fine and well - this is easy. But now I have to persist the changes...
I can think of the following two solutions:
Pass an action (simple a (BookmarkableItem) -> Void) to the BookmarkableItemView that's then called when button in the view is pressed
Have the view itself save the managed object context
The latter is a no-go in my opinion. The first solution works. There's now an bookmarkAction property in the BookmarkableItemView, so the above code looks like that:
List(bookmarkableItems) { item in
BookmarkableItemView(bookmarkAction: { toggleBookmark(item) }, item: item)
}
The action is called by the BookmarkableItemView and as a consequence, the toggleBookmark(_ item:) method is called.
But somehow I feel that I'm missing something about bindings in SwiftUI that would make the process easier? How can I have my parent view get a notification when a sub view performs changes to the data model so I can persist them?
onDisappear check if item.hasChanges
or use onChange on item.hasChanges

Issue with NavigationBarTitle when scrolling List with TextFields inside of it

I have a simple checklist app that uses a SwiftUI List view to display sections with list items inside of it.
Because I want the list items to be editable, each list row has a TextField inside of it with the item name in it. However, if I focus into a text field and scroll the list before hitting the Return Key, the large navigationBarTitle has a bug where it remains frozen in place. For it to work properly, I have to back out of the page and come back.
I'm not seeing any errors, so I'm not sure why this occurring. I have a suspicion using a wrapped UITextField might solve this, but I'm not sure.
I was able to fix my own issue while trying to create a reproducible example.
The bug was the result of a background color I was applying to the parent Group that contained the List. Removing the background color fixed my issue!
struct ChecklistView: View {
var body: some View {
Group {
if empty {
EmptyView()
} else {
ListView()
}
}
.background(Color(.tertiarySystemBackground).edgesIgnoringSafeArea(.all)) // removing this line fixed my issue
.navigationBarTitle("My title")
}
}

How can I get the current index(id) of last visible row in a List or LazyVStack after the user scrolls and reference it after rotation?

I have a LazyVStack in my UI and after device rotation the rows showing are not the same as prior to rotation. Not even close to those rows.
Prior to SwiftUI I was handling this by getting the last visible row in viewWillTransition and then scrolling to it after the orientationDidChangeNotification.
I have not found any way in SwiftUI of detecting when the device will change so that I can get the last row index and scroll to it after rotation.
Is there any equivalent of viewWillTransition or any strategy I can employ to get that functionality?
To be notified of rotation changes in SwiftUI, you can do:
struct ContentView: View {
let rotationChangePublisher = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
var body: some View {
Text("Some View !")
.onReceive(rotationChangePublisher) { _ in
print("View Did Rotate")
}
}
}
To be able to scroll to any row you want at your will, you should read this amazing blog post by Majid: https://swiftwithmajid.com/2020/09/24/mastering-scrollview-in-swiftui/
This is one of the only ways to do what you want in SwiftUI, and i'd say the best way, although its a bit hacky.
EDIT 1:
To be able to scroll to the last row of before the rotation, you need to store the recent last visible rows in a array or something similar, alongside the orientation. Then whenever device changed its orientation you can pick the last value from the array which has the previous orientation, and scroll to that row.

SwiftUI List updating slowly

I have a SwiftUI List, which has thousands of items. Each item is as such:
class Item: Codable, Identifiable {
var id = UUID()
var prop1 = ""
var prop2 = ""
}
Now I do this:
List(store.items.indices, id: \.self) { index in
DetailView(item: self.$store.items[index])
}.id(UUID())
I iterate the indicies because I have to pass a Binding to the DetailView.
Now the DetailView has TextFields attached to the Item's properties.
When DetailView changes an Item's properties, it causes the List to refresh ALL of the items, which I dont want.
Also, it's still important that when one of those properties changes, the main view is updated, because on macOS, the main view is still visible even when you click on one of the items.
My question is how to make it so that whenever an Item passed in from the List changes, only update that row?
I also tried a solution mentioned where I use a LazyVStack, and it fixes the performance issue, but I use the List inside of a NavigationView, and the LazyVStack doesn't seem to support that.
Any suggestions are greatly appreciated.
Remove .id(UUID()) from the List.
By default, List diffs changes and only updates the affected rows. For very large data sets, that .id(UUID()) trick is used to force the whole List to reload when something changes, which may be faster than trying to diff the large dataset. It doesn’t sound like you need it, so it’s actually worse to include it unnecessarily.
Edit: I don’t think binding to the array by index is the problem, but it is unusual and may bite you eventually.
You could use a LazyVStack in a ScrollView as long as you don't need List specific functionality. Lists aren't as efficient as LazyVStacks yet (Apple bug).
ScrollView {
LazyVStack {
ForEach(store.items.indices) { index in
DetailView(item: self.$store.items[index])
}
}
}

How to retrieve view from controller of another view?

I am just wondering how can I get one of the UI5 views in an application.
I know there is a method:
sap.ui.jsview(); // in case the view is written in JavaScript
But the problem with this method is: if you assign ID for any of the controls and you have already inflated this view, you get an error.
So I want to know how to check if the view already exists and then if yes return that existing view, otherwise create the view with the corresponding API such as the above one.
I also know in the control for view I can go
this.getView();
But as I said, how to get this view from another view?
I am not quite understanding your question
With managed object id's are unique, so if you try and create the same view twice you will get an error.
when you create your view the easiest way to access it is via an Id
sap.ui.jsview("view1",'testapp.view.view1');
sap.ui.getCore().byId('view1');
NB. views should not talk to anyone other than their controller A terrific Model View Controller (MVC) diagram
sap.ui.getCore().byId(<id_of_the_view>)
Where <id_of_the_view> can be obtained in the following way:
suppose that the corresponding controller of <id_of_the_view> is "controllerA.js",
then you can console.log, inside controllerA.js,
console.log(this.getView())
This will print you an object which contains the id of such view. This will be <id_of_the_view>
I think here is one solution.
Make a global variable;
Use it to create element.
In First View:
var mytextField ;(use it as global)
mytextField = new sap.ui.commons.TextField('textfieldId');
In Second View:
var myValue = mytextField .getValue();
~Mansi Rao