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? - swift

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.

Related

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

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.

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")
}
}

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 do you stop scrolling to top when grid store is reloaded

I have an app that uses ExtJS 3.3.0. It uses an EditorGridPanel in which after the store is reloaded - I would like it to preserve the scroll position of the grid rather than sending it back up to the top.
Because this is an earlier version of ExtJS - this DOES NOT work:
viewConfig: {
preserveScrollOnRefresh: true
}
So far the only thing I can come up with is to save the position of the scroll bar prior to loading - and then reset the position once the reload is complete.
I can get as far as saving the position:
var scrollPos;
bodyscroll: function(sl, st) {
scrollPos = st;
},
However I can't figure out how to set the position afterwards.
Any suggestions?
Thanks in advance.
There might be multiple ways to do this but one way is to use the scroller element, which is accessible through the grid's gridview. See fiddle here: Grid scoll save/restore.
To get scroll value (which you've already got figured out):
var top = grid.getView().scroller.getScroll().top;
To restore:
grid.getView().scroller.scrollTo('top',top);

insertSubVIew taking a "long" time

In my container controller, the user can pan the views to switch to different views. When the pan gesture begins, it's add the view of the new view controller to the view with: view.insertSubView(view:, atIndex:)
After researching a bit, I noticed this step takes about 0.03 sec (while the other things are all 0.001-0.002 sec). This causes the transition to stotter a bit which is kind of annoying.
The view controller is created at the beginning of the app as a global, using the storyboard.
Also, this only happens when the view is loaded for the first time. The transitions are all fluently after.
What can I do to preload the views so it won't take so "long" when its loaded for the first time?
EDIT:
SURROUNDING CONTEXT:
var pendingViewController: UIViewController! {
didSet {
if let pending = pendingViewController {
addChildViewController(pending)
let index = view.subviews.count - 1
NSLog("start insert view test")
view.insertSubview(pending.view, atIndex: index)
NSLog("end insert view test")
pending.didMoveToParentViewController(self)
}
}
}
Because it only happens when the view is loaded for the first time I was thinking the problem could be somewhere with the viewDidLoad and viewWillAppear. The results are shown below. Only viewDidLoad took a small amount of time (0.005 seconds). There's a gap of 0.02 sec before getting to viewDidLoad though, but I have no idea what it could be.
2015-12-17 15:15:57.116 Valor[777:232799] start insertView view test
2015-12-17 15:15:57.136 Valor[777:232799] start viewDidLoad test
2015-12-17 15:15:57.141 Valor[777:232799] end viewDidLoad test
2015-12-17 15:15:57.142 Valor[777:232799] start viewWillAppear test
2015-12-17 15:15:57.144 Valor[777:232799] end viewWillAppear test
2015-12-17 15:15:57.146 Valor[777:232799] end insertView view test
Use instruments to find out where slow code is happening, not log statements with timestamps. This will show you (including system calls) exactly where the time is being spent.
Inserting subviews can be slow because of layout. However, your trace (such as it is) suggests the time is being spent creating and loading the view of the view controller. You say this view comes from a storyboard. What is in there? How many other things get triggered when this view loads? Use the time profiler and you will be able to tell. It could be something as simple as a property you're giving a default value to that could instead be a lazy value.
The view controller is created at the beginning of the app as a global, using the storyboard.
If this is the case then you can force loading of the view controller's view by doing something like
let hack = viewcontroller.view
Accessing the view property of the view controller causes it to load up the view from the storyboard.
At first sight, I see one strange thing: the index for inserting pending.view. If the view count on your view is 5 for example, that means that the indices of its subviews vary from 0 to 4. And then, you want to put the new subview at index 4 (= 5 - 1) while there's one already on your view which has index = 4. AFAICS, you meant 5 instead of 4. So, you should ether stick to let index = view.subviews.count and then insert the new view at that index, or just use view.addSubview(pending.view). I hope this will help you out.