LazyVStack - row onAppear is called early - swift

I have a LazyVStack, with lots of rows. Code:
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.onAppear {
print("Appeared:", i + 1)
}
}
}
}
}
}
Only about 40 rows are visible on the screen initially, yet onAppear is triggered for 77 rows. Why is this, why is it called before it is actually visible on the screen? I don't see why SwiftUI would have to 'preload' them.
Is there a way to fix this, or if this is intended, how can I accurately know the last visible item (accepting varying row heights)?
Edit
The documentation for LazyVStack states:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
So this must be a bug then, I presume?

By words from the documentation, onAppear shouldn't be like this:
The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.
However, if you are having problems getting this to work properly, see my solution below.
Although I am unsure why the rows onAppears are triggered early, I have created a workaround solution. This reads the geometry of the scroll view bounds and the individual view to track, compares them, and sets whether it is visible or not.
In this example, the isVisible property changes when the top edge of the last item is visible in the scroll view's bounds. This may not be when it is visible on screen, due to safe area, but you can change this to your needs.
Code:
struct ContentView: View {
#State private var isVisible = false
var body: some View {
GeometryReader { geo in
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.background(tracker(index: i))
}
}
}
.onPreferenceChange(TrackerKey.self) { edge in
let isVisible = edge < geo.frame(in: .global).maxY
if isVisible != self.isVisible {
self.isVisible = isVisible
print("Now visible:", isVisible ? "yes" : "no")
}
}
}
}
#ViewBuilder private func tracker(index: Int) -> some View {
if index == 99 {
GeometryReader { geo in
Color.clear.preference(
key: TrackerKey.self,
value: geo.frame(in: .global).minY
)
}
}
}
}
struct TrackerKey: PreferenceKey {
static let defaultValue: CGFloat = .greatestFiniteMagnitude
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}

It works as per my comments above.
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0 ..< 100) { i in
Text("Item: \(i + 1)")
.id(i)
.frame(width: 100, height: 100)
.padding()
.onAppear { print("Appeared:", i + 1) }
}
}
}
}
}

It seems incredible but just adding a GeometryReader containing your ScrollView would resolve the issue
GeometryReader { _ in
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 14) {
Text("Items")
LazyVStack(spacing: 16) {
ForEach(viewModel.data, id: \.id) { data in
MediaRowView(data: data)
.onAppear {
print(data.title, "item appeared")
}
}
if viewModel.state == .loading {
ProgressView()
}
}
}
.padding(.horizontal, 16)
}
}

Related

Can you animate a SwiftUI View on disappear?

I have a SwiftUI View which has a custom animation that runs onAppear. I am trying to get the view to animate onDisappear too but it just immediately vanishes.
The below example reproduces the problem - the MyText view should slide in from the left and slide out to the right. The id modifier is used to ensure a new view is rendered each time the value changes, and I have confirmed that both onAppear and onDisappear are indeed called each time, but the animation onDisappear never visibly runs. How can I achieve this?
struct Survey: View {
#State private var id = 0
var body: some View {
VStack {
MyText(text: "\(id)").id(id)
Button("Increment") {
self.id += 1
}
}
}
struct MyText: View {
#State private var offset: CGFloat = -100
let text: String
var body: some View {
return Text(text)
.offset(x: offset)
.onAppear() {
withAnimation(.easeInOut(duration: 2)) {
self.offset = 0
}
}
.onDisappear() {
withAnimation(.easeInOut(duration: 2)) {
self.offset = 100
}
}
}
}
}
Probably you wanted transition, something like
Update: re-tested with Xcode 13.4 / iOS 15.5
struct Survey: View {
#State private var id = 0
var body: some View {
VStack {
MyText(text: "\(id)")
Button("Increment") {
self.id += 1
}
}
}
struct MyText: View {
var text: String
var body: some View {
Text("\(text)").id(text)
.frame(maxWidth: .infinity)
.transition(.slide)
.animation(.easeInOut(duration: 2), value: text)
}
}
}
I'm afraid it can't work since the .onDisappear modifier is called once the view is hidden.
However there is a nice answer here :
Is there a SwiftUI equivalent for viewWillDisappear(_:) or detect when a view is about to be removed?

Swiftui Scrollview scroll to bottom of list

I'm trying to make my view scroll to the bottom of the item list every time a new item is added.
This is my code, can someone help please?
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader{ value in
VStack() {
ForEach(0..<self.count, id:\.self) {
Text("item \($0)")
}
}.onChange(of: self.data.sampleCount, perform: value.scrollTo(-1))
}
}
We need to give id for views which in ScrollView we want to scroll to.
Here is a demo of solution. Tested with Xcode 12.1 / iOS 14.1
struct DemoView: View {
#State private var data = [String]()
var body: some View {
VStack {
Button("Add") { data.append("Data \(data.count + 1)")}
.padding().border(Color.blue)
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader{ sr in
VStack {
ForEach(self.data.indices, id:\.self) {
Text("item \($0)")
.padding().id($0) // << give id
}
}.onChange(of: self.data.count) { count in
withAnimation {
sr.scrollTo(count - 1) // << scroll to view with id
}
}
}
}
}
}
}
You can add an EmptyView item in the bottom of list
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader{ value in
VStack() {
ForEach(0..<self.count, id:\.self) {
Text("item \($0)")
}
Spacer()
.id("anID") //You can use any type of value
}.onChange(of: self.data.sampleCount, perform: { count in
value.scrollTo("anID")
})
}
Here is my example/solution.
var scrollView : some View {
ScrollView(.vertical) {
ScrollViewReader { scrollView in
ForEach(self.messagesStore.messages) { msg in
VStack {
OutgoingPlainMessageView() {
Text(msg.message).padding(.all, 20)
.foregroundColor(Color.textColorPrimary)
.background(Color.colorPrimary)
}.listRowBackground(Color.backgroundColorList)
Spacer()
}
// 1. First important thing is to use .id
//as identity of the view
.id(msg.id)
.padding(.leading, 10).padding(.trailing, 10)
}
// 2. Second important thing is that we are going to implement //onChange closure for scrollTarget change,
//and scroll to last message id
.onChange(of: scrollTarget) { target in
withAnimation {
scrollView.scrollTo(target, anchor: .bottom)
}
}
// 3. Third important thing is that we are going to implement //onChange closure for keyboardHeight change, and scroll to same //scrollTarget to bottom.
.onChange(of: keyboardHeight){ target in
if(nil != scrollTarget){
withAnimation {
scrollView.scrollTo(scrollTarget, anchor: .bottom)
}
}
}
//4. Last thing is to add onReceive clojure, that will add an action to perform when this ScrollView detects data emitted by the given publisher, in our case messages array.
// This is the place where our scrollTarget is updating.
.onReceive(self.messagesStore.$messages) { messages in
scrollView.scrollTo(messages.last!.id, anchor: .bottom)
self.scrollTarget = messages.last!.id
}
}
}
}
Also take a look at my article on medium.
https://mehobega.medium.com/auto-scrollview-using-swiftui-and-combine-framework-b3e40bb1a99f

SwiftUI - Scroll a list to a specific element controlled by external variable

I have a List populated by Core Data, like this:
#EnvironmentObject var globalVariables : GlobalVariables
#Environment(\.managedObjectContext) private var coreDataContext
#FetchRequest(fetchRequest: Expressao.getAllItemsRequest())
private var allItems: FetchedResults<Expressao>
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(allItems,
id: \.self) { item in
Text(item.term!.lowercased())
.id(allItems.firstIndex(of:item))
.listRowBackground(
Group {
if (globalVariables.selectedItem == nil) {
Color(UIColor.clear)
} else if item == globalVariables.selectedItem {
Color.orange.mask(RoundedRectangle(cornerRadius: 20))
} else {
nextAlternatedColor(item:item)
}
}
}
}
}
}
}
Every time a row is selected it changes color to orange. So, you see that the color is controlled by an external variable located in globalVariables.selectedItem.
I want to be able to make the list scroll to that element on globalVariables.selectedItem automatically.
How do I do that with ScrollViewReader?
Any ideas?
Here is a demo of possible approach - scrollTo can be used only in closure, so the idea is to create some background view depending on row to be scrolled to (this can be achieved with .id) and attach put .scrollTo in .onAppear of that view.
Tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var row = 0
var body: some View {
VStack {
// button here is generator of external selection
Button("Go \(row)") { row = Int.random(in: 0..<50) }
ScrollViewReader { proxy in
List {
ForEach(0..<50) { item in
Text("Item \(item)")
.id(item)
}
}
.background( // << start !!
Color.clear
.onAppear {
withAnimation {
proxy.scrollTo(row, anchor: .top)
}
}.id(row)
) // >> end !!
}
}
}
}

How to implement a grid of three items in collectionView

I am trying to implement grid layout in collectionView. This is my current view
instead of 1 item per collection I would like to show 3 items
this is my productView
struct ProductSearchView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var model: SearchResultViewModel
var body: some View{
NavigationView {
List {
ForEach(0 ..< Global.productArry.count) { value in
Text(Global.productArry[value].name)
CollectionView(model: self.model, data: Global.productArry[value])
}
}.navigationBarTitle("Store")
}
}
}
and this is my collection view
struct CollectionView: View {
#ObservedObject var model: SearchResultViewModel
let data: Product
var body: some View {
VStack {
HStack {
Spacer()
AsyncImage(url: URL(string: self.data.productImageUrl)!, placeholder: Text("Loading ...")
).aspectRatio(contentMode: .fit)
Spacer()
}
VStack {
Spacer()
Text(self.data.name)
Spacer()
}
HStack {
Text("Aisle: \(self.data.location_zone)\(String(self.data.aisleNo))").bold()
Text("$\(String(self.data.productPrice))")
}
}.onAppear(perform:thisVal)
}
func thisVal (){
print(self.data.productImageUrl)
}
}
how can I implement a grid of three items ?
Use a LazyVGrid. The following example is very simple and easy to read and you can change it to anything you need.
struct ContentView: View {
let data = (1...100).map { "Item \($0)" }
let columns = [
GridItem(.adaptive(minimum: 80))
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(data, id: \.self) { _ in
Circle().scaledToFill()
}
}
.padding(.horizontal)
}
}
}
The code snippet below is what you need if you are using SwiftUI version 1 which came with Xcode 11. It is a custom implementation which simply use a combination of ScrollView, VStack and HStack as well as a little maths to calculate the frame size depending on the number of items.
import SwiftUI
struct GridView<Content, T>: View where Content: View {
// MARK: - Properties
var totalNumberOfColumns: Int
var numberRows: Int {
return (items.count - 1) / totalNumberOfColumns
}
var items: [T]
/// A parameter to store the content passed in the ViewBuilder.
let content: (_ calculatedWidth: CGFloat,_ type: T) -> Content
// MARK: - Init
init(columns: Int, items: [T], #ViewBuilder content: #escaping(_ calculatedWidth: CGFloat,_ type: T) -> Content) {
self.totalNumberOfColumns = columns
self.items = items
self.content = content
}
// MARK: - Helpers
/// A function which help checking if the item exist in the specified index to avoid index out of range error.
func elementFor(row: Int, column: Int) -> Int? {
let index:Int = row * self.totalNumberOfColumns + column
return index < items.count ? index : nil
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
ScrollView{
VStack {
ForEach(0...self.numberRows,id: \.self) { (row) in
HStack {
ForEach(0..<self.totalNumberOfColumns) { (column) in
Group {
if (self.elementFor(row: row, column: column) != nil) {
self.content(geometry.size.width / CGFloat(self.totalNumberOfColumns), self.items[self.elementFor(row: row, column: column)!])
.frame(width: geometry.size.width / CGFloat(self.totalNumberOfColumns), height: geometry.size.width / (CGFloat(self.totalNumberOfColumns)), alignment: .center)
}else {
Spacer()
}
}
}
}
}
}
}
}
}
}
Usage: It is generic ready to use with any view of your choice. For instance the Preview code below uses the system images.
struct GridView_Previews: PreviewProvider {
static var previews: some View {
GridView(columns: 3, items: ["doc.text.fill","paperclip.circle.fill", "globe","clear.fill","sun.min.fill", "cloud.rain.fill", "moon","power"], content: { gridWidth,item in
Image(systemName: item)
.frame(width: gridWidth, height: gridWidth, alignment: .center)
.border(Color.orange)
})
.padding(10)
}
}
Output
You can change the code to fit your needs for example paddings etc.
NOTE: Use this method only if you don't have lots of views as it is not very performant. If you have a lot of views to scroll I suggest you use the newer and simple build-in GridItem from Apple which was introduced this year at WWDC. But you will have to use Xcode 12 which currently is available as Beta version.
put for loop logic in HStack like givern below.
HStack {
ForEach(0..<3) { items in
Spacer()
AsyncImage(url: URL(string: self.data.productImageUrl)!, placeholder: Text("Loading ...")
).aspectRatio(contentMode: .fit)
Spacer()
}.padding(.bottom, 16)
}

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