SwiftUI: backwards infinite scroll - ios

I am showing the conversation in the view, initially only the end of the conversation is loaded. To simplify it's something like this:
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(items) { item in
itemView(item)
.onAppear { prependItems(item) }
}
.onAppear {
if let id = items.last?.id {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
}
func prependItems(item: Item) {
// return if already loading
// or if the item that fired onAppear
// is not close to the beginning of the list
// ...
let moreItems = loadPreviousItems(items)
items.insert(contentsOf: moreItems, at: 0)
}
The problem is that when the items are prepended to the list, the list view position remains the same relative to the new start of the list, and trying to programmatically scroll back to the item that fired loading previous items does not work if scrollbar is moving at the time...
A possible solution I can think of would be to flip the whole list view upside down, reverse the list (so that the new items are appended rather than prepended), then flip each item upside down, but firstly it is some terrible hack, and, more importantly, the scrollbar would be on the left...
Is there a better solution for backwards infinite scroll in SwiftUI?
EDIT: it is possible to avoid left scrollbar by using scaleEffect(CGSize(width: 1, height: -1)) instead of rotationEffect(.degrees(180)), but in either case item contextMenu is broken one way or another, so it is not a viable option, unfortunately, as otherwise scaleEffect works reasonably well...
EDIT2: The answer that helps fixing broken context menu, e.g. with a custom context menu in UIKit or in some other way, can also be acceptable, and I posted it to freelancer in case somebody is interested to help with that: https://www.freelancer.com/projects/swift/Custom-UIKit-context-menu-SwiftUI/details

Have you tried this?
self.data = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01)
{
self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2
}
Basically what this does is first empty the array and then set it again to something, but with a little delay. That way the ScrollView empties out, resets the scroll position (as it's empty) and repopulates with the new data and scrolled to the top.

So far the only solution I could find was to flip the view and each item:
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(items.reversed()) { item in
itemView(item)
.onAppear { prependItems(item) }
.scaleEffect(x: 1, y -1)
}
.onAppear {
if let id = items.last?.id {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
.scaleEffect(x: 1, y -1)
}
func prependItems(item: Item) {
// return if already loading
// or if the item that fired onAppear
// is not close to the beginning of the list
// ...
let moreItems = loadPreviousItems(items)
items.insert(contentsOf: moreItems, at: 0)
}
I ended up maintaining reversed model, instead of reversing on the fly as in the above sample, as on long lists the updates become very slow.
This approach breaks swiftUI context menu, as I wrote in the question, so UIKit context menu should be used. The only solution that worked for me, with dynamically sized items and allowing interactions with the item of the list was this one.
What it is doing, effectively, is putting a transparent overlay view with an attached context menu on top of SwiftUI list item, and then putting a copy of the original SwiftUI item on top - not doing this last step makes an item that does not allow tap interactions, so if it were acceptable - it would be better. The linked answer allows to briefly see the edges of the original SwiftUI view during the interaction; it can be avoided by making the original view hidden.
The full code we have is here.
The downside of this approach is that copying the original item prevents its partial updates in the copy, so for every update its view ID must change, and it is more visible to the user when the full update happens... So I believe making a fully custom reverse lazy scroll would be a better (but more complex) solution.
We would like to sponsor the development of reverse lazy scroll as an open-source component/library - we would use it in SimpleX Chat and it would be available for any other messaging applications. Please approach me if you are able and interested to do it.

I found a link related to your question: https://dev.to/gualtierofr/infinite-scrolling-in-swiftui-1p3l
it worked for me so you can implement parts of that in ur code
struct StargazersViewInfiniteScroll: View {
#ObservedObject var viewModel:StargazersViewModel
var body: some View {
List(viewModel.stargazers) { stargazer in
StargazerView(stargazer: stargazer)
.onAppear {
self.elementOnAppear(stargazer)
}
}
}
private func elementOnAppear(_ stargazer:User) {
if self.viewModel.isLastStargazer(stargazer) {
self.viewModel.getNextStargazers { success in
print("next data received")
}
}
}
you can take what you need from here

Related

SwiftUI dismisses the detail view when an NSManagedObject subclass instance changes in a way that moves it to a different section of a fetch request?

The sample app is a default SwiftUI + Core Data template with two modifications. Detail for Item is a separate view where the user can change the timestamp. And sectioned fetch request is used as opposed to a regular one.
#SectionedFetchRequest(
sectionIdentifier: \.monthAndYear, sortDescriptors: [SortDescriptor(\.timestamp)]
) private var items: SectionedFetchResults<String, Item>
var body: some View {
NavigationView {
List(items) { section in
Section(header: Text(section.id)) {
ForEach(section) { item in
NavigationLink {
ItemDetail(item: item)
} label: {
Text(item.timestamp!.formatted())
}
}
}
}
}
}
struct ItemDetail: View {
#ObservedObject var item: Item
#State private var isPresentingDateEditor = false
var body: some View {
Text("Item at \(item.timestamp!.formatted())")
.toolbar {
ToolbarItem(placement: .principal) {
Button(item.timestamp!.formatted()) {
isPresentingDateEditor = true
}
}
}
.sheet(isPresented: $isPresentingDateEditor) {
if let date = Binding($item.timestamp) {
DateEditor(date: date)
}
}
}
}
The problem arises when the user changes the timestamp on an Item to a different month. The detail view dismisses behind the model view arbitrarily. However, the issue is not present if the user changes the timestamp to a day within the same month. It does not matter whether we use a sheet or a full-screen cover. When I was debugging this I noticed that any change of the NSManagedObject subclass instance that will change the section in which it is displayed will dismiss the detail view arbitrarily. I’m expecting to stay on the detail view even if I change the timestamp to a different month.
What is the root cause of this issue and how to fix it?
I think it's because the NavigationLink is no longer active because it has moved to a different section so it has a different structural identity and has defaulted back to inactive. Structural identity is explained in WWDC 2021 Demystify SwiftUI. I reported this as bug FB9977102 hopefully they fix it.
Another major problem with NavigationLink that it doesn't work when offscreen. E.g. in your sample code add lots of rows to fill up more than the screen. Scroll to top, wait one minute (so the picked updates), select first row, adjust the time to current time which will move the row to last in the list and off screen. You'll notice the NavigationLink has deactivated. People face this problem when trying to implement deep links and they can't activate a NavigationLink that is in a row that is off screen.
Update 15th Nov 2022: Apple replied to my feedback FB9977102 "this can be resolved by using the new Navigation APIs to avoid the view identity issues described." NavigationStack seems ok but NavigationSplitView has a row selection bug, see my screen-capture.

Button handling in SwiftUI

In a SwiftUI app I have a few buttons (let us say 3 as an example). One of them is highlighted.
When I tap on a non-highlighted button, the previously highlighted button toggles to non-highlighted and the tapped button becomes highlighted. If I tap the already highlighted button, nothing happens.
This scenario is simple and I have a highlighBtn variable, when a button is tapped highlighBtn takes the value of the tapped button. This variable is then used when the next tap happens to toggle off the previously highlighted button.
This cycle is OK, but the problem is when I do the first tap. For some reasons, things don't work.
This is how I handle the creation of the highlighBtn variable:
class ActiveButton: ObservableObject {
#Published var highlighBtn = Button(....)
}
#StateObject var box = ActiveButton()
Here is the relevant code, when the button is tapped:
#EnvironmentObject var box: ActiveButton
....
Button(action: {
// Toggle off the previously highlighted button.
box.highlighBtn.highLight = false
.... some useful processing ....
box.highlighBtn = self
})
One detail I should give: if I tap the highlighted button to start, then all works as it should.
I have tried various method to solve this apparently simple problem but failed.
The first method was to try to initialize the highlighBtn variable.
The second was to try to simulate a tap on the highlighted button.
I must be missing something simple.
Any tip would be welcome.
After further investigation .....
I have created a demo app to expose my problem.
Since I lack practice using GitHub, it took me some time, but it is now available here.
For that I created a SwiftUI app in Xcode.
In SceneDelegate.swift, the four lines of code right after this one have been customized for the needs of this app:
// Create the SwiftUI view that provides the window contents.
Beside that all I did resides inside the file ContentView.swift.
To save some time to anyone who is going to take a look; here is the way to get to the point (i.e. the issue I am facing).
Run the app (I did it on an iPhone 7). You will see seven buttons appear. One of them (at random) will be highlighted. Starting with the highlighted button, tap on a few buttons one after another as many times as you want. You will see how the app is supposed to work.
(After switching it off) Run the app a second time. This time you will also tap on a few buttons one after another as many times as you want; but start by tapping one of the non-highlighted button. You will see the problem appear at this point.
Here is a solution for the first part of your question: Three buttons where the last one tapped gets highlighted with a background:
import SwiftUI
struct ContentView: View {
enum HighlightTag {
case none, first, second, third
}
#State private var highlighted = HighlightTag.none
var body: some View {
VStack {
Button("First") {
highlighted = .first
}.background(
Color.accentColor.opacity(
highlighted == .first ? 0.2 : 0.0
)
)
Button("Second") {
highlighted = .second
}.background(
Color.accentColor.opacity(
highlighted == .second ? 0.2 : 0.0
)
)
Button("Third") {
highlighted = .third
}.background(
Color.accentColor.opacity(
highlighted == .third ? 0.2 : 0.0
)
)
}
}
}
Update:
After reviewing your sample code on GitHub, I tried to understand your code, I tried to make some simplifications and tried to find a working solution.
Here are some opinions:
The Attribute "#State" in front of "var butnsPool" is not needed and confusing.
The Attribute "#State" in front of "var initialHilight" is not needed and confusing.
Your ActiveButton stores a copy of the selected Button View because it is a struct which is probably the main reason for the strange behaviour.
The needInit in your ObservableObject smells bad at least. If you really need to initialize something, you may consider doing it with some .onAppear() modifier in you ContentView.
There is probably no need to use .environmentObject and #EnvironmentObject. You could consider using a parameter and #ObsservedObject
There is probably no need for the ActiveButton at all, if you only use it internally. You could consider using a #State with the selected utton name
Your BtnTxtView is fine, but consider replacing the conditional (func if) with some animatable properties, if you want to animate the transition.
Based on your code I created a much simpler and working solution.
I removed the ActiveButton class and also the BttnView struct.
And I replaced the ContentView with this:
struct ContentView: View {
var butnsPool: [String]
var initialHilight: Int
#State var selectedBox: String = ""
var body: some View {
ForEach(butnsPool, id: \.self) { buttonName in
Button(action: {
selectedBox = buttonName
})
{
BtnTxtView(theLabel: buttonName,
highLight: buttonName == selectedBox)
}
}.onAppear {
selectedBox = butnsPool[initialHilight]
}
}
}

How do I stop a view from refreshing each time I return from a detail view in SwiftUI?

I have a view which displays a list of posts. I have implemented infinite scrolling, and it is functioning properly. however, there is one small problem I am running into, and attempts to solve it have me going round in circles.
Main view
struct PostsHomeView: View {
#EnvironmentObject var viewModel: ViewModel
#State var dataInitiallyFetched = false
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 0) {
if self.viewModel.posts.count > 0 {
PostsListView(posts: self.viewModel.posts,
isLoading: self.viewModel.canFetchMorePosts,
onScrolledAtBottom: self.viewModel.fetchMorePosts
)
} else {
VStack {
Text("You have no posts!")
}
}
}
.onAppear() {
if !self.dataInitiallyFetched {
self.viewModel.fetchMostRecentPosts()
self.dataInitiallyFetched = true
}
}
.navigationBarTitle("Posts", displayMode: .inline)
}
}
}
List view
struct PostsListView: View {
#EnvironmentObject var viewModel: ViewModel
let posts: [Post]
let isLoading: Bool
let onScrolledAtBottom: () -> Void
var body: some View {
List {
postsList
if isLoading {
loadingIndicator
}
}
}
private var postsList: some View {
ForEach(posts, id: \.self) { post in
PostsCellView(post: post)
.onAppear {
if self.posts.last == post {
self.onScrolledAtBottom()
}
}
}
.id(UUID())
}
}
Problem
Upon tapping one of the posts in the list, I am taken to a detail view. When I tap the nav bar's back button in order go back to the posts list, the whole view is reloaded and my post fetch methods are fired again.
In order to stop the fetch method that fetches most recent posts from firing, I have added a flag that I set to true after the initial load. This stops the fetch method that grabs the initial set of posts from firing when I go back and forth between the details view and posts home screen.
I have tried various things to stop the fetchMorePosts function from firing, but I keep going in circles. I added a guard statement to the top of the fetchMorePosts function in my view model. It checks to see if string is equal to "homeview", if not, then the fetch is not done. I set this string to "detailview" whenever the detail view is visited, then I reset it back to "homeview" in the guard statement.
guard self.lastView == "homeview" else {
self.lastView = "homeview"
return
}
This works to an extent, but I keep finding scenarios where it doesn't work as expected. There must be a straight-forward way to tell SwiftUI not to reload a view. The problem is the method sits in the onAppear closure which is vital for the infinite scrolling to work. I'm not using iOS 14 yet, so I can't use #StateObject.
Is there a way to tell SwiftUI not to fire onAppear everytime I return from a detail view?
Thanks in advance
The culprit was .id(UUID()). I removed it from my list and everything worked again.
Thanks Asperi. Your help is much appreciated.

SwiftUI: NavigationLink pops immediately if used within ForEach

I'm using a NavigationLink inside of a ForEach in a List to build a basic list of buttons each leading to a separate detail screen.
When I tap on any of the list cells, it transitions to the detail view of that cell but then immediately pops back to the main menu screen.
Not using the ForEach helps to avoid this behavior, but not desired.
Here is the relevant code:
struct MainMenuView: View {
...
private let menuItems: [MainMenuItem] = [
MainMenuItem(type: .type1),
MainMenuItem(type: .type2),
MainMenuItem(type: .typeN),
]
var body: some View {
List {
ForEach(menuItems) { item in
NavigationLink(destination: self.destination(item.destination)) {
MainMenuCell(menuItem: item)
}
}
}
}
// Constructs destination views for the navigation link
private func destination(_ destination: ScreenDestination) -> AnyView {
switch destination {
case .type1:
return factory.makeType1Screen()
case .type2:
return factory.makeType2Screen()
case .typeN:
return factory.makeTypeNScreen()
}
}
If you have a #State, #Binding or #ObservedObject in MainMenuView, the body itself is regenerated (menuItems get computed again) which causes the NavigationLink to invalidate (actually the id change does that). So you must not modify the menuItems arrays id-s from the detail view.
If they are generated every time consider setting a constant id or store in a non modifying part, like in a viewmodel.
Maybe I found the reason of this bug...
if you use iOS 15 (not found iOS 14),
and you write the code NavigationLink to go to same View in different locations in your projects, then this bug appear.
So I simply made another View that has different destination View name but the same contents... then it works..
you can try....
sorry for my poor English...

Bottom-first scrolling in SwiftUI

How can I make a SwiftUI List start scrolling from the bottom of the screen (like a chat view)?
Ideally, I want to mimic, e.g. the behavior of iMessage when the list updates, meaning it shifts down if an item is added when the user is at the bottom, but holds it’s position if the user manually scrolled up.
The list is read directly from a binding array, and the order can be reversed if convenient.
#komal pointed out that the UITableView (the backend of List) has an atScrollPosition that should provide this functionality. However, there doesn't seem to be a way to access the underlying view without completely reimplementing List as a UIViewRepresentable, which is easier said than done, considering the standard implementation is completely black-boxed and closed-source.
With that said, I've also posted Access underlying UITableView from SwiftUI List, which, if solved, could serve as an answer to this question.
I would suggest that instead of using scrollview, use UITableView as Tablview inherits scroll property of UIScrollview. And you can use "atScrollPosition:UITableViewScrollPositionBottom" to achieve this behavior.
You could try this way of inverting a ScrollView:
struct ContentView: View {
let degreesToFlip: Double = 180
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
ForEach(0..<100) { i in
HStack {
Spacer()
Text("Number: \(i)")
Spacer()
}
.rotationEffect(.degrees(self.degreesToFlip))
}
.rotationEffect(.degrees(self.degreesToFlip))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The problem with this approach is that you inverse everything in the ScrollView so you inverse the content also but the scroll indicator also gets reversed so instead of being on the right side the indicator is now on the left side. Maybe there is some configuration to always have the indicator on the right side.
Note that I have disabled the scroll indicator in this example code and that this also applies on List but in lists I didn't find any ways to hide the scroll indicator.
Hope this helps a little.

Resources