I don't understand why the Text updates but the List does not.
I have a model with a #Published which is an array of dates.
There's also a method to generate text from the dates.
Here's a simplified version:
class DataModel: NSObject, ObservableObject {
#Published var selectedDates: [Date] = []
func summary() -> String {
var lines: [String] = []
for date in selectedDates.sorted().reversed() {
let l = "\(someOtherStuff) \(date.dateFormatted)"
lines.append(l)
}
return lines.joined(separator: "\n")
}
}
And I show this text in my view. Here's a simplified version:
struct DataView: View {
#ObservedObject var model: DataModel
var body: some View {
ScrollView {
Text(model.summary())
}
}
}
It works: when the user, from the UI in the View, adds a date to model.selectedDates, the summary text is properly udpated.
Now I want to replace the text with a List of lines.
I change the method:
func summary() -> [String] {
var lines: [String] = []
for date in selectedDates.sorted().reversed() {
let l = "\(someOtherStuff) \(date.dateFormatted)"
lines.append(l)
}
return lines
}
And change the view:
var body: some View {
ScrollView {
List {
ForEach(model.summary(), id: \.self) { line in
Text(line)
}
}
}
}
But this doesn't work: there's no text at all in the list, it is never updated when the user adds a date to model.selectedDates.
What am I doing wrong?
I was able to find this answer to a previous post on SO that may be the solution to your problem. When you say that there was no text at all in the list, did you debug to verify that, or was there simply no data being shown on the screen?
When I made a copy of your code snippet and fiddled with it, it appeared that there actually was data in the list that the List was attempting to display on the screen, but the frame of the List was being overridden by the Scrollview. Nesting everything inside of a GeometryReader and giving a frame size to the List gave the program the functionality it sounds like you are looking for.
Either use a ScrollView or a List for your data. If a List has multiple values, it automatically turns into a scrollable List. So, I believe you can just remove the ScrollView entirely.
Here is a fixed worked variant (with some replications - so you need to adapt it back to your project).
Tested with Xcode 12.1 / iOS 14.1
class DataModel: NSObject, ObservableObject {
#Published var selectedDates: [Date] = []
func summary() -> [String] {
var lines: [String] = []
for date in selectedDates.sorted().reversed() {
let l = "someOtherStuff \(date)"
lines.append(l)
}
return lines
}
}
struct DataView: View {
#ObservedObject var model: DateDataModel = DataModel()
var body: some View {
VStack {
Button("Add") { model.selectedDates.append(Date())}
List {
ForEach(model.summary(), id: \.self) { line in
Text(line)
}
}
}
}
}
Related
struct Item: Identifiable {
let id: String
let url = URL(string: "https://styles.redditmedia.com/t5_j6lc8/styles/communityIcon_9uopq0bazux01.jpg")!
}
struct Content: View {
let model: [Item] = {
var model = [Item]()
for i in 0 ..< 100 {
model.append(.init(id: String(i)))
}
return model
}()
var body: some View {
List(model) { item in
Row(item: item)
}
}
}
struct Row: View {
let item: Item
var body: some View {
AsyncImage(url: item.url)
}
}
Running code above with Xcode 14.1 on iOS 16.1 simulator, AsyncImage sometimes doesn’t properly show downloaded image but grey rectangle instead when scrolling in list. Is this bug or am I missing something? Thanks
My solution was to use VStack in ScrollView instead of List. It looks like it's working and it doesn't have any other drawbacks.
I experience kind of weird behavior of placing Text view in nested ForEach in one LazyVGrid view. My code look like this
LazyVGrid(columns: dataColumns) {
ForEach(months, id: \.self) { month in
Text("\(dateFormatter.string(from: month))")
}
ForEach(categories) { category in
ForEach(months, id: \.self) { month in
Text("aaa")
}
}
}
and "aaa" string is not shown but if I add just another Text view like this
ForEach(months, id: \.self) { month in
Text("aaa")
Text("bbb")
}
then both Text views with strings are repeatedly shown as expected. Any idea? Thanks.
The issue here is to do with view identity. LazyVGrid uses each views identity to know when they need to be loaded in.
Structural identity is also used by SwiftUI to identify views based on the structure of their view hierarchy. This is why when you added another view to the ForEach instance the views show, as they now are uniquely structurally identifiable from the other ForEach content.
Here, you have two separate ForEach instances inside the LazyVGrid which use the same identifiers. Two of the forEach instances are using months with the id \.self to identify them. These are conflicting and should be unique to avoid these sorts of issues.
To fix this you should preferably use a unique array of elements for each ForEach instance, though could create a copy of the months array to use in the second ForEach instead of referring to the same instances. Here's an example:
import SwiftUI
import MapKit
struct MyType: Identifiable, Hashable {
var id = UUID()
var name = "Tester"
}
struct ContentView: View {
let columns = [GridItem(.flexible()), GridItem(.flexible())]
var months: [MyType] = []
var monthsCopy: [MyType] = []
private var monthValues: [MyType] {
[MyType()]
}
init() {
months = monthValues
monthsCopy = monthValues
}
var body: some View {
LazyVGrid(columns: columns) {
ForEach(months) { _ in
Text("Test1")
Text("Test2")
}
ForEach(monthsCopy) { _ in
Text("Test3")
Text("Test4")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you replace ForEach(monthsCopy) with ForEach(months) you will see the issue you were faced with.
I'm doing a comparison of Core Data and Realm in a SwiftUI app, and Core Data does something that I'm hoping to figure out how to do in Realm.
Core Data lets you mutate objects whenever you want, and when they are ObservableObject in SwiftUI, your UI instantly updates. You then save the context whenever you want to persist the changes.
In Realm, the objects in the UI are live, but you can't change them unless you are in a write transaction. I'm trying to get my UI to reflect live/instant changes from the user when the actual write is only performed occasionally. Below is a sample app.
Here's my Realm model:
import RealmSwift
class Item: Object, ObjectKeyIdentifiable{
#objc dynamic var recordName = ""
#objc dynamic var text = ""
override class func primaryKey() -> String? {
return "recordName"
}
}
Here is my view model that also includes my save() function that only saves every 3 seconds. In my actual app, this is because it's an expensive operation and doing it as the user types brings the app to a crawl.
class ViewModel: ObservableObject{
static let shared = ViewModel()
#Published var items: Results<Item>!
#Published var selectedItem: Item?
var token: NotificationToken? = nil
init(){
//Add dummy data
let realm = try! Realm()
realm.beginWrite()
let item1 = Item()
item1.recordName = "one"
item1.text = "One"
realm.add(item1, update: .all)
let item2 = Item()
item2.recordName = "two"
item2.text = "Two"
realm.add(item2, update: .all)
try! realm.commitWrite()
self.fetch()
//Notifications
token = realm.objects(Item.self).observe{ [weak self] _ in
self?.fetch()
}
}
//Get Data
func fetch(){
let realm = try! Realm()
self.items = realm.objects(Item.self)
}
//Save Data
var saveTimer: Timer?
func save(item: Item, text: String){
//Save occasionally
saveTimer?.invalidate()
saveTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false){ _ in
let realm = try! Realm()
try? realm.write({
item.text = text
})
}
}
}
Last of all, here is the UI. It's pretty basic and reflects the general structure of my app where I'm trying to pull this off.
struct ContentView: View {
#StateObject var model = ViewModel.shared
var body: some View {
VStack{
ForEach(model.items){ item in
HStack{
Button(item.text){
model.selectedItem = item
}
Divider()
ItemDetail(item: item)
}
}
}
}
}
...and the ItemDetail view:
struct ItemDetail: View{
#ObservedObject var item: Item
#StateObject var model = ViewModel.shared
init(item: Item){
self.item = item
}
var body: some View{
//Binding
let text = Binding<String>(
get: { item.text },
set: { model.save(item: item, text: $0) }
)
TextField("Text...", text: text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
When I type in the TextField, how do I get the Button text to reflect what I have typed in real time considering that my realm.write only happens every 3 seconds? My Button updates after a write, but I want the UI to respond live--independent of the write.
Based on the suggested documentation from Jay, I got the following to work which is quite a bit simpler:
My main view adds the #ObservedResults property wrapper like this:
struct ContentView: View {
#StateObject var model = ViewModel.shared
#ObservedResults(Item.self) var items
var body: some View {
VStack{
ForEach(items){ item in
HStack{
Button(item.text){
model.selectedItem = item
}
Divider()
ItemDetail(item: item)
}
}
}
}
}
...and then the ItemDetail view simply uses an #ObservedRealmObject property wrapper that binds to the value in Realm and manages the writes automatically:
struct ItemDetail: View{
#ObservedRealmObject var item: Item
var body: some View{
TextField("Text...", text: $item.text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
This is essentially how Core Data does it (in terms view code) except Realm saves to the store automatically. Thank you, Jay!
I want to allow the user to filter data in a long list to more easily find matching titles.
I have placed a TextView inside my navigation bar:
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: TextField("search", text: $modelData.searchString)
I have an observable object which responds to changes in the search string:
class DataModel: ObservableObject {
#Published var modelData: [PDFSummary]
#Published var searchString = "" {
didSet {
if searchString == "" {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name })
} else {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name }).filter({ $0.name.lowercased().contains(searchString.lowercased()) })
}
}
}
Everything works fine, except I have to tap on the field after entering each letter. For some reason the focus is taken away from the field after each letter is entered (unless I tap on a suggested autocorrect - the whole string is correctly added to the string at once)
The problem is in rebuilt NavigationView completely that result in dropped text field focus.
Here is working approach. Tested with Xcode 11.4 / iOS 13.4
The idea is to avoid rebuild NavigationView based on knowledge that SwiftUI engine updates only modified views, so using decomposition we make modifications local and transfer desired values only between subviews directly not affecting top NavigationView, as a result the last kept stand.
class QueryModel: ObservableObject {
#Published var query: String = ""
}
struct ContentView: View {
// No QueryModel environment object here -
// implicitly passed down. !!! MUST !!!
var body: some View {
NavigationView {
ResultsView()
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: SearchItem())
}
}
}
struct ResultsView: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
var body: some View {
VStack {
Text("Search: \(qm.query)") // receive query string
}
}
}
struct SearchItem: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
#State private var query = "" // updates only local view
var body: some View {
let text = Binding(get: { self.query }, set: {
self.query = $0; self.qm.query = $0; // transfer query string
})
return TextField("search", text: text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(QueryModel())
}
}
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()
}
}