In SwiftUI, where are the control events, i.e. scrollViewDidScroll to detect the bottom of list data - swift

In SwiftUI, does anyone know where are the control events such as scrollViewDidScroll to detect when a user reaches the bottom of a list causing an event to retrieve additional chunks of data? Or is there a new way to do this?
Seems like UIRefreshControl() is not there either...

Plenty of features are missing from SwiftUI - it doesn't seem to be possible at the moment.
But here's a workaround.
TL;DR skip directly at the bottom of the answer
An interesting finding whilst doing some comparisons between ScrollView and List:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
I appended a Rectangle at the end of 100 Text items inside a ScrollView, with a print in onDidAppear.
It fired when the ScrollView appeared, even if it showed the first 20 items.
All views inside a Scrollview are rendered immediately, even if they are offscreen.
I tried the same with List, and the behaviour is different.
struct ContentView: View {
var body: some View {
List {
ForEach(1...100) { item in
Text("\(item)")
}
Rectangle()
.onAppear { print("Reached end of scroll view") }
}
}
}
The print gets executed only when the bottom of the List is reached!
So this is a temporary solution, until SwiftUI API gets better.
Use a List and place a "fake" view at the end of it, and put fetching logic inside onAppear { }

You can to check that the latest element is appeared inside onAppear.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if let last == self.items.last {
print("last item")
self.items += last+1...last+30
}
}
}
}
}
}

In case you need more precise info on how for the scrollView or list has been scrolled, you could use the following extension as a workaround:
extension View {
func onFrameChange(_ frameHandler: #escaping (CGRect)->(),
enabled isEnabled: Bool = true) -> some View {
guard isEnabled else { return AnyView(self) }
return AnyView(self.background(GeometryReader { (geometry: GeometryProxy) in
Color.clear.beforeReturn {
frameHandler(geometry.frame(in: .global))
}
}))
}
private func beforeReturn(_ onBeforeReturn: ()->()) -> Self {
onBeforeReturn()
return self
}
}
The way you can leverage the changed frame like this:
struct ContentView: View {
var body: some View {
ScrollView {
ForEach(0..<100) { number in
Text("\(number)").onFrameChange({ (frame) in
print("Origin is now \(frame.origin)")
}, enabled: number == 0)
}
}
}
}
The onFrameChange closure will be called while scrolling. Using a different color than clear might result in better performance.
edit: I've improved the code a little bit by getting the frame outside of the beforeReturn closure. This helps in the cases where the geometryProxy is not available within that closure.

I tried the answer for this question and was getting the error Pattern matching in a condition requires the 'case' keyword like #C.Aglar .
I changed the code to check if the item that appears is the last of the list, it'll print/execute the clause. This condition will be true once you scroll and reach the last element of the list.
struct ContentView: View {
#State var items = Array(1...30)
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
.onAppear {
if item == self.items.last {
print("last item")
fetchStuff()
}
}
}
}
}
}

The OnAppear workaround works fine on a LazyVStack nested inside of a ScrollView, e.g.:
ScrollView {
LazyVStack (alignment: .leading) {
TextField("comida", text: $controller.searchedText)
switch controller.dataStatus {
case DataRequestStatus.notYetRequested:
typeSomethingView
case DataRequestStatus.done:
bunchOfItems
case DataRequestStatus.waiting:
loadingView
case DataRequestStatus.error:
errorView
}
bottomInvisibleView
.onAppear {
controller.loadNextPage()
}
}
.padding()
}
The LazyVStack is, well, lazy, and so only create the bottom when it's almost on the screen

I've extracted the LazyVStack plus invisible view in a view modifier for ScrollView that can be used like:
ScrollView {
Text("Some long long text")
}.onScrolledToBottom {
...
}
The implementation:
extension ScrollView {
func onScrolledToBottom(perform action: #escaping() -> Void) -> some View {
return ScrollView<LazyVStack> {
LazyVStack {
self.content
Rectangle().size(.zero).onAppear {
action()
}
}
}
}
}

Related

SwiftUI NavigationLink style within ScrollView

I have a screen where I'm trying to display a list of NavigationLink and a grid of items (using LazyVGrid). I first tried putting everything in a List, like this:
List() {
ForEach(items) { item in
NavigationLink(destination: MyDestination()) {
Text("Navigation link text")
}
}
LazyVGrid(columns: columns) {
ForEach(gridItems) { gridItem in
MyGridItem()
}
}
}
However, it seems that putting a LazyVGrid in a List doesn't load the items in the grid lazily, it loads them all at once. So I replaced the List with a ScrollView and it works properly. However, I do want to keep the style of the NavigationLink that is shown when they are in a List. Basically what this looks like https://www.simpleswiftguide.com/wp-content/uploads/2019/10/Screen-Shot-2019-10-05-at-3.00.34-PM.png instead of https://miro.medium.com/max/800/1*LT7ZwIaidXrMuR6pu1Jvgg.png.
How can this be achieved? Or is there a way to put a LazyVGrid in a List and still have it load lazily?
Take a loot at this, the List is already a built-in Lazy load scroll view, you can check that is lazy on the onAppear event
import SwiftUI
struct item:Identifiable {
let id = UUID()
let value: String
}
struct listTest: View {
#State var items: [item]
init () {
var newItems = [item]()
for i in 1...50 {
newItems.append(item(value: "ITEM # \(i)"))
}
self.items = newItems
}
var body: some View {
NavigationView {
List($items) { $item in
NavigationLink(destination: MyDestination(value: item.value)) {
Text("Navigation link text")
.onAppear{
print("IM ITEM: \(item.value)")
}
.id(UUID())
}
}
}
}
}
struct MyDestination: View {
let value: String
var body: some View {
ZStack{
Text("HI A DETAIL: \(value)")
}
}
}

SwiftUI: Animate changes in List without animating content changes

I have a simple app in SwiftUI that shows a List, and each item is a VStack with two Text elements:
var body: some View {
List(elements) { item in
NavigationLink(destination: DetailView(item: item)) {
VStack(alignment: .leading) {
Text(item.name)
Text(self.distanceString(for: item.distance))
}
}
}
.animation(.default)
}
The .animate() is in there because I want to animate changes to the list when the elements array changes. Unfortunately, SwiftUI also animates any changes to content, leading to weird behaviour. For example, the second Text in each item updates quite frequently, and an update will now shortly show the label truncated (with ... at the end) before updating to the new content.
So how can I prevent this weird behaviour when I update the list's content, but keep animations when the elements in the list change?
In case it's relevant, I'm creating a watchOS app.
The following should disable animations for row internals
VStack(alignment: .leading) {
Text(item.name)
Text(self.distanceString(for: item.distance))
}
.animation(nil)
The answer by #Asperi fixed the issue I was having also (Upvoted his answer as always).
I had an issue where I was animating the whole screen in using the below: AnyTransition.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
And all the Text() and Button() sub views where also animating in weird and not so wonderful ways. I used animation(nil) to fix the issue after seeing Asperi's answer. However the issue was that my Buttons no longer animated on selection, along with other animations I wanted.
So I added a new State variable to turn on and off the animations of the VStack. They are off by default and after the view has been animated on screen I enable them after a small delay:
struct QuestionView : View {
#State private var allowAnimations : Bool = false
var body : some View {
VStack(alignment: .leading, spacing: 6.0) {
Text("Some Text")
Button(action: {}, label:Text("A Button")
}
.animation(self.allowAnimations ? .default : nil)
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.allowAnimations = true
}
}
}
}
Just adding this for anyone who has a similar issue to me and needed to build on Asperi's excellent answer.
Thanks to #Brett for the delay solution. My code needed it in several places, so I wrapped it up in a ViewModifier.
Just add .delayedAnimation() to your view.
You can pass parameters for defaults other than one second and the default animation.
import SwiftUI
struct DelayedAnimation: ViewModifier {
var delay: Double
var animation: Animation
#State private var animating = false
func delayAnimation() {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.animating = true
}
}
func body(content: Content) -> some View {
content
.animation(animating ? animation : nil)
.onAppear(perform: delayAnimation)
}
}
extension View {
func delayedAnimation(delay: Double = 1.0, animation: Animation = .default) -> some View {
self.modifier(DelayedAnimation(delay: delay, animation: animation))
}
}
In my case any of the above resulted in strange behaviours. The solution was to animate the action that triggered the change in the elements array instead of the list. For example:
#State private var sortOrderAscending = true
// Your list of elements with some sorting/filtering that depends on a state
// In this case depends on sortOrderAscending
var elements: [ElementType] {
let sortedElements = Model.elements
if (sortOrderAscending) {
return sortedElements.sorted { $0.name < $1.name }
} else {
return sortedElements.sorted { $0.name > $1.name }
}
}
var body: some View {
// Your button or whatever that triggers the sorting/filtering
// Here is where we use withAnimation
Button("Sort by name") {
withAnimation {
sortOrderAscending.toggle()
}
}
List(elements) { item in
NavigationLink(destination: DetailView(item: item)) {
VStack(alignment: .leading) {
Text(item.name)
}
}
}
}

SwiftUI View does not updated when ObservedObject changed

I have a content view where i'm showing a list of items using ForEach
#ObservedObject var homeVM : HomeViewModel
var body: some View {
ScrollView (.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
Spacer().frame(width:8)
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
Spacer().frame(width:8)
}
}.frame(height: 100)
}
And my ObservableObject contains
#Published var favoriteStores = [StoreViewModel]()
And StoreViewModel is defined as below
class StoreViewModel : ObservableObject {
#Published var store:ItemStore
init(store:ItemStore) {
self.store = store
}
var _id:String {
return store._id!
}
}
If i fill the array directly it work fine , and i can see the list of stores
but if i filled the array after a network call (background job) , the view did not get notified that there is a change
StoreApi().getFavedStores(userId: userId) { (response) in
guard response != nil else {
self.errorMsg = "Something wrong"
self.error = true
return
}
self.favoriteStores = (response)!.map(StoreViewModel.init)
print("counnt favorite \(self.favoriteStores.count)")
}
But the weird thing is :
- if i add a text which show the count of the array items , the view will get notified and everything work fine
That's what i mean :
#ObservedObject var homeVM : HomeViewModel
var body: some View {
ScrollView (.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
Spacer().frame(width:8)
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
Text("\(self.homeVM.favoriteStores.count)").frame(width: 100, height: 100)
Spacer().frame(width:8)
}
}.frame(height: 100)
}
Any explaining for that ?
For horizontal ScrollView you need to have your height fixed or just use conditional ScrollView which will show when someModels.count > 0
var body: some View {
ScrollView(.horizontal) {
HStack() {
ForEach(someModels, id: \.someParameter1) { someModel in
someView(someModel)
}
}.frame(height: someFixedHeight)
}
}
// or
var body: some View {
VStack {
if someModels.count > 0 {
ScrollView(.horizontal) {
HStack() {
ForEach(someModels, id: \.someParameter1) { someModel in
someView(someModel)
}
}
}
} else {
EmptyView()
// or other view
}
}
}
Properly conforming to Hashable protocol by implementing
func hash(into hasher: inout Hasher) {
hasher.combine(someParameter1)
hasher.combine(someParameter2)
}
and
static func == (lhs: SomeModel, rhs: SomeModel) -> Bool {
lhs.someParameter1 == rls.someParameter1 && lhs.someParameter2 == rls.someParameter2
}
And also following #Asperi suggestion of using proper id from model, which must be unique to every element.
ForEach(someModels, id: \.someParameter1) { someModel in
someView(someModel)
}
There is no other way for ForEach to notify updates other than unique ids.
Text(someModels.count) was working because it is notifying the changes as count is being changed.
Share some experiences that might help.
When I used ScrollView + ForEach + fetch, nothing shows until I trigger view refresh via other means. It shows normally when I supply fixed data without fetching. I solved it by specifying .infinity width for ScrollView.
My theory is that ForEach re-renders when fetch completes, but ScrollView somehow does not adjust its width to fit the content.
It is similar in your case. By supplying fixed width Text, ForEach can calculate its width during init, and ScrollView can use that to adjust its width. But in case of fetch, ForEach has initial 0 content width, and so is ScrollView. When fetch completes ScrollView does not update its width accordingly.
Giving ScrollView some initial width should solve your problem.
I would start from forwarding results to the main queue, as below
DispatchQueue.main.async {
self.favoriteStores = (response)!.map(StoreViewModel.init)
}
Next... ForEach tracks id parameters, so you should be sure that results have different _id s in the following, otherwise ForEach is not marked as needed to update
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
What is ItemStore? more importantly
ForEach(homeVM.favoriteStores , id: \._id){ item in
StoreRowOneView(storeVM: item)
}
what is _id i believe your _id is same each time so it will not update the list. You have to put id: some variable that you want to re-render the list when it changed.
Update: If you do not have any other variable to take its value as reference than you can use
ForEach((0..<homeVM.favoriteStores.count)) { index in
StoreRowOneView(storeVM:: homeVM.favoriteStores[index])
}

What is the correct way to update ScrollView in SwiftUI? (MacOS App)

I am using officially released Xcode 11 from appstore.
A number of things can be seen if you run the code below.
Tapping on Root Press correctly adds the missing view#2, But there is no animation. Why is there no animation?
Tapping any of the Press buttons, correctly adds a middle view, but if you look, you will see that the scrollView content size did not update and therefore the content is clipped. What is the correct way to update the ScrollView?
import SwiftUI
struct ContentView: View {
#State var isPressed = false
var body: some View {
VStack {
Button("Root Press") { withAnimation { self.isPressed.toggle() } }
ScrollView {
Group {
SampleView(index: 1)
if isPressed { SampleView(index: 2) }
SampleView(index: 3)
SampleView(index: 4)
}
.border(Color.red)
}
}
}
}
struct SampleView: View {
#State var index: Int
#State var isPressed = false
var body: some View {
HStack {
VStack {
Text("********************")
Text("This View = \(index)")
Text("********************")
if isPressed {
Text("********************")
Text("-----> = \(index)")
Text("********************")
}
}
Button("Press") { withAnimation { self.isPressed.toggle() } }
}
}
}
Fixing the animations can be done via: .animation(.linear(duration: 0.3)). You can then remove all the animation blocks. (withAnimation { }). As for the bounds/frame, setting the frame helps (when adding the row at the root level), but it doesn't seem to work when you are dealing with an inner view. I added the following: .frame(width:UIScreen.main.bounds.width), and it will look like the following:

SwiftUI: Animate Cells within a Form

I am trying to animate my Form or rather the cells within it. My problem is that the following code give me a nice insertion animation but for the removal the cell is suddenly removed after am ugly looking delay.
import SwiftUI
struct ContentView: View {
#State var toggledValue = false
#State var pickedValue = 0
var body: some View {
NavigationView {
Form {
Section {
Toggle(isOn: $toggledValue) {
Text("Toggled Value")
}
if toggledValue {
Picker(selection: $pickedValue, label: Text("Picked Value")) {
ForEach((0...5).identified(by: \.self)) {
Text("Pick Value \($0)").tag($0)
}
}
}
}
Section {
Text("Some Text")
}
}
.navigationBarTitle("Navigation Bar Title")
}
}
}
What I tried so far is to to wrap the Toggle in a withAnimation closure but this does not change anything. What makes me wondering is that the same code using List instead of Form gives me the expected Animation. Is that a bug or am I overseeing something?
This will probably work (tested in iOS 16 in a similar situation):
Add #State private var isShowingPicker = false
Replace if toggledValue by if isShowingPicker
Under .navigationBarTitle(...) add:
onChange(of: toggledValue)
{ newValue in
withAnimation { isShowingPicker = newValue }
}