Bottom-first scrolling in SwiftUI - ios

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.

Related

SwiftUI: backwards infinite scroll

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

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.

SwiftUI - testing - simulate tap gesture?

Is it somehow possible to simulate a tap when testing (ex. snapshot tests) a tap or any other gesture in SwiftUI?
For UIKit we can do something like:
button.sendActions(for: .touchUpInside)
Is there any SwiftUI equivalent?
While it's not directly possible to "Simulate" in the fashion you're attempting to simulate, it is perfectly possible to simulate the actions behind the buttons. This is assuming that you're using an MVVM architecture. The reason for this is that if you "Simulate" via the backing methods that support the buttons, via the view model, then you will still get the same result. In addition to this, SwiftUI will update and recalculate the views upon any state change, meaning it doesn't matter if the button changes a state or if a method changes the state. You can then extend that functionality to the init() function of the view struct, and viola, you'll be simulating actions.
View Model Example
class VMExample: ObservableObject {
#Published var shouldNavigate = false
func simulateNavigate() {
shouldNavigate.toggle
}
}
View Example
struct MyView: View {
#ObservedObject var vm = VMExample()
var body: some View {
NavigationLink(
"Navigate",
destination: Text("New View"),
isActive: $vm.shouldNavigate)
.onAppear {
//If Debug
vm.simulateNavigate()
}
}
}
Simulating multiple actions
To do it with multiple actions, you could potentially create some function func beginSimulation() that begins running through all the actions you want to test. You might change some text, navigate to a view, etc...
TL;DR
Simulate the actions behind the buttons, not the buttons interactions themselves. The result will be the same due to View Binding.

ProgressView hides on list scroll

I have a List with a ProgressView and some rows. When I scroll the List down and up again, the ProgressView get hidden, I notice this bug only with a certain number of rows, for example on iPhone 13 you can reproduce this bug if you have 20 rows.
struct ContentView: View {
var body: some View {
List {
ProgressView()
ForEach(0..<20, id: \.self) {
Text("\($0)")
}
}
}
}
I know that it's probably too late. But I also had this problem and figured it might be caused by SwiftUI "recovering" your previously used view that (for some reason) it's now hidden.
So what I did to fix this was just adding a unique ID to my progress view, this way SwiftUI won't try to re-utilize it.
Ex:
ProgressView().id(UUID())

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...

Resources