Reusing Swift UI LazyVGrid is destroying the frames of grid item - swiftui

I have tow view FolderView and FilesView
struct FoldersView: View {
var folderData : [FolderModel]?
var body: some View {
ScrollView {
LazyVGrid(columns: SizeConfig.GridLayout.adaptiveGridItemLayout_170) {
if let foldersObj = folderData, foldersObj.count > 0 {
ForEach(foldersObj) { folderItem in
MakeGridFolders(folderData: folderItem)
}
} else {
CommonViews.WarningText(msg: "No folders are available")
}
}
.padding([.leading, .trailing, .top], SizeConfig.Spacing.default)
}
}
struct FilesView: View {
var filesData : [Files]?
var isFolderSpecificFile: Bool = false
var navigationTitle = App.NavigationTitle.entityDetail
var body: some View {
ScrollView {
LazyVGrid(columns: SizeConfig.GridLayout.adaptiveGridItemLayout_170) {
if let filesObj = filesData, filesObj.count > 0 {
ForEach(filesObj, id: \.uid) { fileObj in
MakeGridFiles(file: fileObj)
}
} else {
CommonViews.WarningText(msg: "No recent files are uploaded")
}
}
.padding([.leading, .trailing, .top], SizeConfig.Spacing.default)
}
.navigationTitle(navigationTitle)
}
While i'm reusing the FilesView on tap of folder open all files, everything is working fine but when i go back to original FilesView from the tab grids layout are destroyed.
I'm new in SwftUI, not sure what i'm making wrong here.
Attaching video for better understanding.

The issue was because of filesData : [Files]? is getting appended every time on scroll.
Simple change is replace #SateObject with #State and #Binding
OR
manually avoid appending data on each scroll.

Related

Why does this SwiftUI LazyHStack update continuously?

I have a large set of URLs to images. I display the files' thumbnails in a LazyVStack. I have wrapped up the 'ThumbnailView' and the 'ThumbnailGenerator' in a struct and class respectively. However, when I ran the code I discovered that it kept re-initaiting the ThumbnailGenerators. After some investigation I found that after removing an HStack in the main view's hierarchy the problem went away.
Any thoughts as to why this might happen. (BTW I did log this with Apple, but still feel I am doing something wrong here myself.)
I have stripped the code back to the bare essentials here, replacing the thumbnail generation code with a simple sleep statement, to demonstrate the bug in action. Run it with the HStack in and it will print out the date continuously. Take it out and it works as expected.
#main
struct ExperimentApp: App {
var body: some Scene {
WindowGroup {
LazyVIssue()
.frame(width: 200, height: 140)
.padding(100)
}
}
}
struct LazyVIssue: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<10) { i in
HStack { /// <---- REMOVE THIS HSTACK AND IT WORKS
ThumbnailView()
Text("Filename \(i)")
}.padding()
}
}
}
}
}
struct ThumbnailView: View {
#StateObject private var thumbnailGenerator : ThumbnailGenerator
init() {
_thumbnailGenerator = StateObject(wrappedValue: ThumbnailGenerator())
}
var body: some View {
thumbnailGenerator.image
}
}
final class ThumbnailGenerator: ObservableObject {
var image : Image
init() {
print("Initiating", Date())
image = Image(systemName: "questionmark.circle.fill")
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
guard let self = self else { return }
sleep(1) /// Simulate some work to fetch image
self.image = Image(systemName: "camera.circle.fill")
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
}
I'm not sure why this is happening but I've seen had some funky things happen like this as well. If you initialize the ThumbnailGenerator() outside of the ThumbnailView init, I believe the issue goes away.
init(generator: ThumbnailGenerator) {
_thumbnailGenerator = StateObject(wrappedValue: generator)
}
Well, it is not clear for now what's going on here definitely (it is something about LazyVStack caching), but there is workaround - move everything into single row view.
Tested with Xcode 12.1 / iOS 14.1
struct LazyVIssue: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<10) { i in
ThumbnailView(i) // << single row view !!
}
}
}
}
}
struct ThumbnailView: View {
#StateObject private var thumbnailGenerator : ThumbnailGenerator
let row: Int
init(_ row: Int) {
self.row = row
_thumbnailGenerator = StateObject(wrappedValue: ThumbnailGenerator())
}
var body: some View {
HStack {
thumbnailGenerator.image
Text("Filename \(row)")
}.padding()
}
}

SwifUI onAppear gets called twice

Q1: Why are onAppears called twice?
Q2: Alternatively, where can I make my network call?
I have placed onAppears at a few different place in my code and they are all called twice. Ultimately, I'm trying to make a network call before displaying the next view so if you know of a way to do that without using onAppear, I'm all ears.
I have also tried to place and remove a ForEach inside my Lists and it doesn't change anything.
Xcode 12 Beta 3 -> Target iOs 14
CoreData enabled but not used yet
struct ChannelListView: View {
#EnvironmentObject var channelStore: ChannelStore
#State private var searchText = ""
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List() {
ForEach(channelStore.allChannels) { channel in
NavigationLink(destination: VideoListView(channel: channel)
.onAppear(perform: {
print("PREVIOUS VIEW ON APPEAR")
})) {
ChannelRowView(channel: channel)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
struct VideoListView: View {
#EnvironmentObject var videoStore: VideoStore
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var channel: Channel
var body: some View {
List(videoStore.allVideos) { video in
VideoRowView(video: video)
}
.onAppear(perform: {
print("LIST ON APPEAR")
})
.navigationTitle("Videos")
.navigationBarItems(trailing: Button(action: {
networking.getTopVideos(channelID: channel.channelId) { (videos) in
var videoIdArray = [String]()
videoStore.allVideos = videos
for video in videoStore.allVideos {
videoIdArray.append(video.videoID)
}
for (index, var video) in videoStore.allVideos.enumerated() {
networking.getViewCount(videoID: videoIdArray[index]) { (viewCount) in
video.viewCount = viewCount
videoStore.allVideos[index] = video
networking.setVideoThumbnail(video: video) { (image) in
video.thumbnailImage = image
videoStore.allVideos[index] = video
}
}
}
}
}) {
Text("Button")
})
.onAppear(perform: {
print("BOTTOM ON APPEAR")
})
}
}
I had the same exact issue.
What I did was the following:
struct ContentView: View {
#State var didAppear = false
#State var appearCount = 0
var body: some View {
Text("Appeared Count: \(appearrCount)"
.onAppear(perform: onLoad)
}
func onLoad() {
if !didAppear {
appearCount += 1
//This is where I loaded my coreData information into normal arrays
}
didAppear = true
}
}
This solves it by making sure only what's inside the the if conditional inside of onLoad() will run once.
Update: Someone on the Apple Developer forums has filed a ticket and Apple is aware of the issue. My solution is a temporary hack until Apple addresses the problem.
I've been using something like this
import SwiftUI
struct OnFirstAppearModifier: ViewModifier {
let perform:() -> Void
#State private var firstTime: Bool = true
func body(content: Content) -> some View {
content
.onAppear{
if firstTime{
firstTime = false
self.perform()
}
}
}
}
extension View {
func onFirstAppear( perform: #escaping () -> Void ) -> some View {
return self.modifier(OnFirstAppearModifier(perform: perform))
}
}
and I use it instead of .onAppear()
.onFirstAppear{
self.vm.fetchData()
}
you can create a bool variable to check if first appear
struct VideoListView: View {
#State var firstAppear: Bool = true
var body: some View {
List {
Text("")
}
.onAppear(perform: {
if !self.firstAppear { return }
print("BOTTOM ON APPEAR")
self.firstAppear = false
})
}
}
Let us assume you are now designing a SwiftUI and your PM is also a physicist and philosopher. One day he tells you we should to unify UIView and UIViewController, like Quantum Mechanics and the Theory of Relativity. OK, you are like-minded with your leader, voting for "Simplicity is Tao", and create an atom named "View". Now you say: "View is everything, view is all". That sounds awesome and seems feasible. Well, you commit the code and tell the PM….
onAppear and onDisAppear exists in every view, but what you really need is a Page lifecycle callback. If you use onAppear like viewDidAppear, then you get two problems:
Being influenced by the parent, the child view will rebuild more than one time, causing onAppear to be called many times.
SwiftUI is closed source, but you should know this: view = f(view). So, onAppear will run to return a new View, which is why onAppear is called twice.
I want to tell you onAppear is right! You MUST CHANGE YOUR IDEAS. Don’t run lifecycle code in onAppear and onDisAppear! You should run that code in the "Behavior area". For example, in a button navigating to a new page.
You can create the first appear function for this bug
extension View {
/// Fix the SwiftUI bug for onAppear twice in subviews
/// - Parameters:
/// - perform: perform the action when appear
func onFirstAppear(perform: #escaping () -> Void) -> some View {
let kAppearAction = "appear_action"
let queue = OperationQueue.main
let delayOperation = BlockOperation {
Thread.sleep(forTimeInterval: 0.001)
}
let appearOperation = BlockOperation {
perform()
}
appearOperation.name = kAppearAction
appearOperation.addDependency(delayOperation)
return onAppear {
if !delayOperation.isFinished, !delayOperation.isExecuting {
queue.addOperation(delayOperation)
}
if !appearOperation.isFinished, !appearOperation.isExecuting {
queue.addOperation(appearOperation)
}
}
.onDisappear {
queue.operations
.first { $0.name == kAppearAction }?
.cancel()
}
}
}
For everyone still having this issue and using a NavigationView. Add this line to the root NavigationView() and it should fix the problem.
.navigationViewStyle(StackNavigationViewStyle())
From everything I have tried, this is the only thing that worked.
We don't have to do it on .onAppear(perform)
This can be done on init of View
In case someone else is in my boat, here is how I solved it for now:
struct ChannelListView: View {
#State private var searchText = ""
#State private var isNavLinkActive: Bool = false
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List(channelStore.allChannels) { channel in
ZStack {
NavigationLink(destination: VideoListView(channel: channel)) {
ChannelRowView(channel: channel)
}
HStack {
Spacer()
Button {
isNavLinkActive = true
// Place action/network call here
} label: {
Image(systemName: "arrow.right")
}
.foregroundColor(.gray)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
I've got this app:
#main
struct StoriesApp: App {
var body: some Scene {
WindowGroup {
TabView {
NavigationView {
StoriesView()
}
}
}
}
}
And here is my StoriesView:
// ISSUE
struct StoriesView: View {
#State var items: [Int] = []
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
///////////////////////////////////
// Gets called 2 times on app start <--------
///////////////////////////////////
}
}
I've resolved the issue by measuring the diff time between onAppear() calls. According to my observations double calls of onAppear() happen between 0.02 and 0.45 seconds:
// SOLUTION
struct StoriesView: View {
#State var items: [Int] = []
#State private var didAppearTimeInterval: TimeInterval = 0
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
if Date().timeIntervalSince1970 - didAppearTimeInterval > 0.5 {
///////////////////////////////////////
// Gets called only once in 0.5 seconds <-----------
///////////////////////////////////////
}
didAppearTimeInterval = Date().timeIntervalSince1970
}
}
In my case, I found that a few views up the hierarchy, .onAppear() (and .onDisappear()) was only being called once, as expected. I used that to post notifications that I listen to down in the views that need to take action on those events. It’s a gross hack, and I’ve verified that the bug is fixed in iOS 15b1, but Apple really needs to backport the fix.

SwiftUI: .sheet() doesn't go to the previous view with expected data when dismiss current sheet

As minimal, my code is like below. In SinglePersonView When user tap one image of movie in MovieListView(a movie list showing actor attended movies), then it opens the SingleMovieView as sheet mode.
The sheet could be popped up as tapping. But I found after close the sheet and re-select other movie in MovieListView, the sheet always opened as my previous clicked movie info aka the first time chosen one. And I could see in console, the movie id is always the same one as the first time. I get no clues now, do I need some reloading operation on the dismissal or something else?
And is it the correct way to use .sheet() in subView in SwiftUI, or should always keep it in the main body, SinglePersonView in this case.
struct SinglePersonView: View {
var personId = -1
#ObservedObject var model = MovieListViewModel()
var body: some View {
ScrollView() {
VStack() {
...
MovieListView(movies: model.movies)
...
}
}.onAppear {
// json API request
}
}
}
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.showSheet.toggle()
}
.sheet(isPresented: self.$showSheet) {
SingleMovieView(movieId: movie.id)
}
}
}
}
}
}
There should be only one .sheet in view stack, but in provided snapshot there are many which activated all at once - following behaviour is unpredictable, actually.
Here is corrected variant
struct MovieListView: View {
var movies: [PersonMovieViewModel]
#State private var showSheet = false
#State private var selectedID = "" // type of your movie's ID
var body: some View {
ScrollView() {
HStack() {
ForEach(movies) { movie in
VStack() {
Image(...)
.onTapGesture {
self.selectedID = movie.id
self.showSheet.toggle()
}
}
}
}
.sheet(isPresented: $showSheet) {
SingleMovieView(movieId: selectedID)
}
}
}
}

SwiftUI: Picker content not refreshed when adding Element

I have a Picker Element in a VStack, but when its content changes by adding a new Element, the Picker does not refresh.
After hiding and showing the Picker, the new Element is visible.
Does anybody have any idea how to refresh the content of a Picker, without needing to hide / show it?
You can reproduce this by creating a new SwiftUI project and copying the following code instead of the "ContentView" struct.
class ContentModel {
#Published var pickerData: [String] = ["1"]
func addPickerData() {
pickerData.append("\(pickerData.count + 1)")
}
}
struct ContentView: View {
let contentModel = ContentModel()
#State private var showPicker = false
#State private var selectedPickerValue = ""
var body: some View {
VStack(spacing: 8) {
Text("Adding a new Element to the Picker does not refresh its content :-(")
Button(action: {
self.contentModel.addPickerData()
}) {
Text("Add Picker Data")
}
Button(action: {
self.showPicker.toggle()
}) {
Text("Show / Hide 2nd Picker")
}
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
if (showPicker) {
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}
Text("Selected Value: \(selectedPickerValue)")
}
}
}
Thanks in advance for any help!
Here is the trick of reactive and always use two copies of same thing when you need to refresh something.
class ContentModel{
#Published var pickerData: [String] = ["1"]
func addPickerData() {
pickerData.append("\(pickerData.count + 1)")
}
}
struct ContentSSView: View {
let contentModel = ContentModel()
#State private var showPicker = false
#State private var selectedPickerValue = ""
var body: some View {
VStack(spacing: 8) {
Text("Adding a new Element to the Picker does not refresh its content :-(")
Button(action: {
self.contentModel.addPickerData()
self.showPicker.toggle()
}) {
Text("Add Picker Data")
}
Button(action: {
self.showPicker.toggle()
}) {
Text("Show / Hide 2nd Picker")
}
if (showPicker) {
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}else{
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}
Text("Selected Value: \(selectedPickerValue)")
}
}
}
I have a GitHub repo with this issue. I don't think having two Pickers is a viable solution.
Picker Update Bug GitHub Repo

How do I efficiently filter a long list in SwiftUI?

I've been writing my first SwiftUI application, which manages a book collection. It has a List of around 3,000 items, which loads and scrolls pretty efficiently. If use a toggle control to filter the list to show only the books I don't have the UI freezes for twenty to thirty seconds before updating, presumably because the UI thread is busy deciding whether to show each of the 3,000 cells or not.
Is there a good way to do handle updates to big lists like this in SwiftUI?
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
ForEach(userData.bookList) { book in
if !self.userData.showWantsOnly || !book.own {
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
Have you tried passing a filtered array to the ForEach. Something like this:
ForEach(userData.bookList.filter { return !$0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) { BookRow(book: book) }
}
Update
As it turns out, it is indeed an ugly, ugly bug:
Instead of filtering the array, I just remove the ForEach all together when the switch is flipped, and replace it by a simple Text("Nothing") view. The result is the same, it takes 30 secs to do so!
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if self.userData.showWantsOnly {
Text("Nothing")
} else {
ForEach(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
}
Workaround
I did find a workaround that works fast, but it requires some code refactoring. The "magic" happens by encapsulation. The workaround forces SwiftUI to discard the List completely, instead of removing one row at a time. It does so by using two separate lists in two separate encapsualted views: Filtered and NotFiltered. Below is a full demo with 3000 rows.
import SwiftUI
class UserData: ObservableObject {
#Published var showWantsOnly = false
#Published var bookList: [Book] = []
init() {
for _ in 0..<3001 {
bookList.append(Book())
}
}
}
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if userData.showWantsOnly {
Filtered()
} else {
NotFiltered()
}
}
}.navigationBarTitle(Text("Books"))
}
}
struct Filtered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList.filter { $0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct NotFiltered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct Book: Identifiable {
let id = UUID()
let own = Bool.random()
}
struct BookRow: View {
let book: Book
var body: some View {
Text("\(String(book.own)) \(book.id)")
}
}
struct BookDetail: View {
let book: Book
var body: some View {
Text("Detail for \(book.id)")
}
}
Check this article https://www.hackingwithswift.com/articles/210/how-to-fix-slow-list-updates-in-swiftui
In short the solution proposed in this article is to add .id(UUID()) to the list:
List(items, id: \.self) {
Text("Item \($0)")
}
.id(UUID())
"Now, there is a downside to using id() like this: you won't get your update animated. Remember, we're effectively telling SwiftUI the old list has gone away and there's a new list now, which means it won't try to move rows around in an animated way."
I think we have to wait until SwiftUI List performance improves in subsequent beta releases. I’ve experienced the same lag when lists are filtered from a very large array (500+) down to very small ones. I created a simple test app to time the layout for a simple array with integer IDs and strings with Buttons to simply change which array is being rendered - same lag.
Instead of a complicated workaround, just empty the List array and then set the new filters array. It may be necessary to introduce a delay so that emptying the listArray won't be omitted by the followed write.
List(listArray){item in
...
}
self.listArray = []
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.listArray = newList
}
Looking for how to adapt Seitenwerk's response to my solution, I found a Binding extension that helped me a lot. Here is the code:
struct ContactsView: View {
#State var stext : String = ""
#State var users : [MockUser] = []
#State var filtered : [MockUser] = []
var body: some View {
Form{
SearchBar(text: $stext.didSet(execute: { (response) in
if response != "" {
self.filtered = []
self.filtered = self.users.filter{$0.name.lowercased().hasPrefix(response.lowercased()) || response == ""}
}
else {
self.filtered = self.users
}
}), placeholder: "Buscar Contactos")
List{
ForEach(filtered, id: \.id){ user in
NavigationLink(destination: LazyView( DetailView(user: user) )) {
ContactCell(user: user)
}
}
}
}
.onAppear {
self.users = LoadUserData()
self.filtered = self.users
}
}
}
This is the Binding extension:
extension Binding {
/// Execute block when value is changed.
///
/// Example:
///
/// Slider(value: $amount.didSet { print($0) }, in: 0...10)
func didSet(execute: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
execute($0)
self.wrappedValue = $0
}
)
}
}
The LazyView is optional, but I took the trouble to show it, as it helps a lot in the performance of the list, and prevents swiftUI from creating the NavigationLink target content of the whole list.
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
This code will work correctly provided that you initialize your class in the 'SceneDelegate' file as follows:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var userData = UserData()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView:
contentView
.environmentObject(userData)
)
self.window = window
window.makeKeyAndVisible()
}
}