I have a SwiftUI app with SwiftUI App lifecycle that includes a master-detail type
list driven from CoreData. I have the standard list in ContentView and NavigationLinks
to the DetailView. I pass a Core Data entity object to the Detailview.
My struggle is setting-up bindings to TextFields in the DetailView for data entry
and for editing. I tried to create an initializer which I could not make work. I have
only been able to make it work with the following. Assigning the initial values
inside the body does not seem like the best way to do this, though it does work.
Since the Core Data entities are ObservableObjects I thought I should be able to
directly access and update bound variables, but I could not find any way to reference
a binding to Core Data in a ForEach loop.
Is there a way to do this that is more appropriate than my code below?
Simplified Example:
struct DetailView: View {
var thing: Thing
var count: Int
#State var localName: String = ""
#State private var localComment: String = ""
#State private var localDate: Date = Date()
//this does not work - cannot assign String? to State<String>
// init(t: Thing) {
// self._localName = t.name
// self._localComment = t.comment
// self._localDate = Date()
// }
var body: some View {
//this is the question - is this safe?
DispatchQueue.main.async {
self.localName = self.thing.name ?? "no name"
self.localComment = self.thing.comment ?? "No Comment"
self.localDate = self.thing.date ?? Date()
}
return VStack {
Text("\(thing.count)")
.font(.title)
Text(thing.name ?? "no what?")
TextField("name", text: $localName)
Text(thing.comment ?? "no comment?")
TextField("comment", text: $localComment)
Text("\(thing.date ?? Date())")
//TextField("date", text: $localDate)
}.padding()
}
}
And for completeness, the ContentView:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Thing.date, ascending: false)])
private var things : FetchedResults<Thing>
#State private var count: Int = 0
#State private var coverDeletedDetail = false
var body: some View {
NavigationView {
List {
ForEach(things) { thing in
NavigationLink(destination: DetailView(thing: thing, count: self.count + 1)) {
HStack {
Image(systemName: "gear")
.resizable()
.frame(width: 40, height: 40)
.onTapGesture(count: 1, perform: {
updateThing(thing)
})
Text(thing.name ?? "untitled")
Text("\(thing.count)")
}
}
}
.onDelete(perform: deleteThings)
if UIDevice.current.userInterfaceIdiom == .pad {
NavigationLink(destination: WelcomeView(), isActive: self.$coverDeletedDetail) {
Text("")
}
}
}
.navigationTitle("Thing List")
.navigationBarItems(trailing: Button("Add Task") {
addThing()
})
}
}
private func updateThing(_ thing: FetchedResults<Thing>.Element) {
withAnimation {
thing.name = "Updated Name"
thing.comment = "Updated Comment"
saveContext()
}
}
private func deleteThings(offsets: IndexSet) {
withAnimation {
offsets.map { things[$0] }.forEach(viewContext.delete)
saveContext()
self.coverDeletedDetail = true
}
}
private func addThing() {
withAnimation {
let newThing = Thing(context: viewContext)
newThing.name = "New Thing"
newThing.comment = "New Comment"
newThing.date = Date()
newThing.count = Int64(self.count + 1)
self.count = self.count + 1
saveContext()
}
}
func saveContext() {
do {
try viewContext.save()
} catch {
print(error)
}
}
}
And Core Data:
extension Thing {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Thing> {
return NSFetchRequest<Thing>(entityName: "Thing")
}
#NSManaged public var comment: String?
#NSManaged public var count: Int64
#NSManaged public var date: Date?
#NSManaged public var name: String?
}
extension Thing : Identifiable {
}
Any guidance would be appreciated. Xcode 12.2 iOS 14.2
You already mentioned it. CoreData works great with SwiftUI.
Just make your Thing as ObservableObject
#ObservedObject var thing: Thing
and then you can pass values from thing as Binding. This works in ForEach aswell
TextField("name", text: $thing.localName)
For others - note that I had to use the Binding extension above since NSManagedObjects are optionals. Thus as davidev stated:
TextField("name", text: Binding($thing.name, "no name"))
And ObservedObject, not Observable
Related
I'm trying to make an app that is displaying lists with selections/checkmarks based on clicked NavigationLink. The problem I encountered is that my selections disappear when I go back to main view and then I go again inside the NavigationLink. I'm trying to save toggles value in UserDefaults but it's not working as expected. Below I'm pasting detailed and main content view.
Second view:
struct CheckView: View {
#State var isChecked:Bool = false
#EnvironmentObject var numofitems: NumOfItems
var title:String
var count: Int=0
var body: some View {
HStack{
ScrollView {
Toggle("\(title)", isOn: $isChecked)
.toggleStyle(CheckToggleStyle())
.tint(.mint)
.onChange(of: isChecked) { value in
if isChecked {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
UserDefaults.standard.set(self.isChecked, forKey: "locationToggle")
}.onTapGesture {
}
.onAppear {
self.isChecked = UserDefaults.standard.bool(forKey: "locationToggle")
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
Main view:
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach(menu){
section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach(section.items) { item in
CheckView( title: item.name)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
ItemsSection:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria"
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola"
}
Model:
struct ItemsSection: Codable, Identifiable, Hashable {
var id: UUID = UUID()
var name: String
var items: [CountriesItem]
}
struct CountriesItem: Codable, Equatable, Identifiable,Hashable {
var id: UUID = UUID()
var name: String
}
As allready stated in the comment you have to relate the isChecked property to the CountryItem itself. To get this to work i have changed the model and added an isChecked property. You would need to add this to the JSON by hand if the JSON allread exists.
struct CheckView: View {
#EnvironmentObject var numofitems: NumOfItems
//use a binding here as we are going to manipulate the data coming from the parent
//and pass the complete item not only the name
#Binding var item: CountriesItem
var body: some View {
HStack{
ScrollView {
//use the name and the binding to the item itself
Toggle("\(item.name)", isOn: $item.isChecked)
.toggleStyle(.button)
.tint(.mint)
// you now need the observe the isChecked inside of the item
.onChange(of: item.isChecked) { value in
if value {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
}.onTapGesture {
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach($menu){ // from here on you have to pass a binding on to the decendent views
// mark the $ sign in front of the property name
$section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach($section.items) { $item in
//Pass the complete item to the CheckView not only the name
CheckView(item: $item)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
Example JSON:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria",
"isChecked": false
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola",
"isChecked": false
}
]
}
]
Remarks:
The aproach with JSON and storing this in the bundle will prevent you from persisting the isChecked property between App launches. Because you cannot write to the Bundle from within your App. The choice will persist as long as the App is active but will be back to default as soon as you either reinstall or force quit it.
As already mentioned in the comment, I don'r see where you read back from UserDefaults, so whatever gets stored there, you don't read it. But even if so, each Toggle is using the same key, so you are overwriting the value.
Instead of using the #State var isChecked, which is used just locally, I'd create another struct item which gets the title from the json and which contains a boolean that gets initialized with false.
From what I understood, I assume a solution could look like the following code. Just a few things:
I am not sure how your json looks like, so I am not loading from a json, I add ItemSections Objects with a title and a random number of items (actually just titles again) with a function.
Instead of a print with the number of checked toggles, I added a text output on the UI. It shows you on first page the number of all checked toggles.
Instead of using UserDefaults I used #AppStorage.
To make that work you have to make Array conform to RawRepresentable you achieve that with the following code/extension (just add it once somewhere in your project)
Maybe you should thing about a ViewModel (e.g. ItemSectionViewModel), to load the data from the json and provide it to the views as an #ObservableObject.
The code for the views:
//
// CheckItem.swift
// CheckItem
//
// Created by Sebastian on 24.08.22.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack() {
CheckItemView()
}
}
}
struct CheckItemView: View {
let testStringForTestData: String = "Check Item Title"
#AppStorage("itemSections") var itemSections: [ItemSection] = []
func addCheckItem(title: String, numberOfItems: Int) {
var itemArray: [Item] = []
for i in 0...numberOfItems {
itemArray.append(Item(title: "item \(i)"))
}
itemSections.append(ItemSection(title: title, items: itemArray))
}
func getSelectedItemsCount() -> Int{
var i: Int = 0
for itemSection in itemSections {
let filteredItems = itemSection.items.filter { item in
return item.isOn
}
i = i + filteredItems.count
}
return i
}
var body: some View {
NavigationView{
VStack() {
List(){
ForEach(itemSections.indices, id: \.self){ id in
NavigationLink(destination: ItemSectionDetailedView(items: $itemSections[id].items)) {
Text(itemSections[id].title)
}
.padding()
}
}
Text("Number of checked items: \(self.getSelectedItemsCount())")
.padding()
Button(action: {
self.addCheckItem(title: testStringForTestData, numberOfItems: Int.random(in: 0..<4))
}) {
Text("Add Item")
}
.padding()
}
}
}
}
struct ItemSectionDetailedView: View {
#Binding var items: [Item]
var body: some View {
ScrollView() {
ForEach(items.indices, id: \.self){ id in
Toggle(items[id].title, isOn: $items[id].isOn)
.padding()
}
}
}
}
struct ItemSection: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var items: [Item]
}
struct Item: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var isOn: Bool = false
}
Here the adjustment to work with #AppStorage:
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}
Here is a simple list view of "Topic" struct items. The goal is to present an editor view when a row of the list is tapped. In this code, tapping a row is expected to cause the selected topic to be stored as "tappedTopic" in an #State var and sets a Boolean #State var that causes the EditorV to be presented.
When the code as shown is run and a line is tapped, its topic name prints properly in the Print statement in the Button action, but then the app crashes because self.tappedTopic! finds tappedTopic to be nil in the EditTopicV(...) line.
If the line "tlVM.objectWillChange.send()" is uncommented, the code runs fine. Why is this needed?
And a second puzzle: in the case where the code runs fine, with the objectWillChange.send() uncommented, a print statement in the EditTopicV init() shows that it runs twice. Why?
Any help would be greatly appreciated. I am using Xcode 13.2.1 and my deployment target is set to iOS 15.1.
Topic.swift:
struct Topic: Identifiable {
var name: String = "Default"
var iconName: String = "circle"
var id: String { name }
}
TopicListV.swift:
struct TopicListV: View {
#ObservedObject var tlVM: TopicListVM
#State var tappedTopic: Topic? = nil
#State var doEditTappedTopic = false
var body: some View {
VStack(alignment: .leading) {
List {
ForEach(tlVM.topics) { topic in
Button(action: {
tappedTopic = topic
// why is the following line needed?
tlVM.objectWillChange.send()
doEditTappedTopic = true
print("Tapped topic = \(tappedTopic!.name)")
}) {
Label(topic.name, systemImage: topic.iconName)
.padding(10)
}
}
}
Spacer()
}
.sheet(isPresented: $doEditTappedTopic) {
EditTopicV(tlVM: tlVM, originalTopic: self.tappedTopic!)
}
}
}
EditTopicV.swift (Editor View):
struct EditTopicV: View {
#ObservedObject var tlVM: TopicListVM
#Environment(\.presentationMode) var presentationMode
let originalTopic: Topic
#State private var editTopic: Topic
#State private var ic = "circle"
let iconList = ["circle", "leaf", "photo"]
init(tlVM: TopicListVM, originalTopic: Topic) {
print("DBG: EditTopicV: originalTopic = \(originalTopic)")
self.tlVM = tlVM
self.originalTopic = originalTopic
self._editTopic = .init(initialValue: originalTopic)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
Spacer()
Button("Save") {
editTopic.iconName = editTopic.iconName.lowercased()
tlVM.change(topic: originalTopic, to: editTopic)
presentationMode.wrappedValue.dismiss()
}
}
HStack {
Text("Name:")
TextField("name", text: $editTopic.name)
Spacer()
}
Picker("Color Theme", selection: $editTopic.iconName) {
ForEach(iconList, id: \.self) { icon in
Text(icon).tag(icon)
}
}
.pickerStyle(.segmented)
Spacer()
}
.padding()
}
}
TopicListVM.swift (Observable Object View Model):
class TopicListVM: ObservableObject {
#Published var topics = [Topic]()
func append(topic: Topic) {
topics.append(topic)
}
func change(topic: Topic, to newTopic: Topic) {
if let index = topics.firstIndex(where: { $0.name == topic.name }) {
topics[index] = newTopic
}
}
static func ex1() -> TopicListVM {
let tvm = TopicListVM()
tvm.append(topic: Topic(name: "leaves", iconName: "leaf"))
tvm.append(topic: Topic(name: "photos", iconName: "photo"))
tvm.append(topic: Topic(name: "shapes", iconName: "circle"))
return tvm
}
}
Here's what the list looks like:
Using sheet(isPresented:) has the tendency to cause issues like this because SwiftUI calculates the destination view in a sequence that doesn't always seem to make sense. In your case, using objectWillSend on the view model, even though it shouldn't have any effect, seems to delay the calculation of your force-unwrapped variable and avoids the crash.
To solve this, use the sheet(item:) form:
.sheet(item: $tappedTopic) { item in
EditTopicV(tlVM: tlVM, originalTopic: item)
}
Then, your item gets passed in the closure safely and there's no reason for a force unwrap.
You can also capture tappedTopic for a similar result, but you still have to force unwrap it, which is generally something we want to avoid:
.sheet(isPresented: $doEditTappedTopic) { [tappedTopic] in
EditTopicV(tlVM: tlVM, originalTopic: tappedTopic!)
}
I have 3 views. Content View, TrainingView and TrainingList View. I want to list exercises from Core Data but also I want to make some changes without changing data.
In ContentView; I am trying to fetch data with CoreData
struct ContentView: View {
// MARK: - PROPERTY
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Training.timestamp, ascending: false)],
animation: .default)
private var trainings: FetchedResults<Training>
#State private var showingAddProgram: Bool = false
// FETCHING DATA
// MARK: - FUNCTION
// MARK: - BODY
var body: some View {
NavigationView {
Group {
VStack {
HStack {
Text("Your Programs")
Spacer()
Button(action: {
self.showingAddProgram.toggle()
}) {
Image(systemName: "plus")
}
.sheet(isPresented: $showingAddProgram) {
AddProgramView()
}
} //: HSTACK
.padding()
List {
ForEach(trainings) { training in
TrainingListView(training: training)
}
} //: LIST
Spacer()
} //: VSTACK
} //: GROUP
.navigationTitle("Good Morning")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
print("test")
}) {
Image(systemName: "key")
}
}
} //: TOOLBAR
.onAppear() {
}
} //: NAVIGATION
}
private func showId(training: Training) {
guard let id = training.id else { return }
print(id)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
In TrainingView; I am getting exercises as a array list and I am pushing into to TrainingListView.
import SwiftUI
struct TrainingView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#State var training: Training
#State var exercises: [Exercise]
#State var tempExercises: [Exercise] = [Exercise]()
#State var timeRemaining = 0
#State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State var isTimerOn = false
var body: some View {
VStack {
HStack {
Text("\(training.name ?? "")")
Spacer()
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Finish")
}
}
.padding()
ZStack {
Circle()
.fill(Color.blue)
.frame(width: 250, height: 250)
Circle()
.fill(Color.white)
.frame(width: 240, height: 240)
Text("\(timeRemaining)s")
.font(.system(size: 100))
.fontWeight(.ultraLight)
.onReceive(timer) { _ in
if isTimerOn {
if timeRemaining > 0 {
timeRemaining -= 1
} else {
isTimerOn.toggle()
stopTimer()
removeExercise()
}
}
}
}
Button(action: {
startResting()
}) {
if isTimerOn {
Text("CANCEL")
} else {
Text("GIVE A BREAK")
}
}
Spacer()
ExerciseListView(exercises: $tempExercises)
}
.navigationBarHidden(true)
.onAppear() {
updateBigTimer()
}
}
private func startResting() {
tempExercises = exercises
if let currentExercise: Exercise = tempExercises.first {
timeRemaining = Int(currentExercise.rest)
startTimer()
isTimerOn.toggle()
}
}
private func removeExercise() {
if let currentExercise: Exercise = tempExercises.first {
if Int(currentExercise.rep) == 1 {
let index = tempExercises.firstIndex(of: currentExercise) ?? 0
tempExercises.remove(at: index)
} else if Int(currentExercise.rep) > 1 {
currentExercise.rep -= 1
let index = tempExercises.firstIndex(of: currentExercise) ?? 0
tempExercises.remove(at: index)
tempExercises.insert(currentExercise, at: index)
}
updateBigTimer()
}
}
private func updateBigTimer() {
timeRemaining = Int(tempExercises.first?.rest ?? 0)
}
private func stopTimer() {
timer.upstream.connect().cancel()
}
private func startTimer() {
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
}
struct TrainingView_Previews: PreviewProvider {
static var previews: some View {
TrainingView(training: Training(), exercises: [Exercise]())
}
}
In TrainingListView; I am listing all exercises.
struct TrainingListView: View {
#ObservedObject var training: Training
#Environment(\.managedObjectContext) private var managedObjectContext
var body: some View {
NavigationLink(destination: TrainingView(training: training, exercises: training.exercises?.toArray() ?? [Exercise]())) {
HStack {
Text("\(training.name ?? "")")
Text("\(training.exercises?.count ?? 0) exercises")
}
}
}
}
Also, I am adding video: https://twitter.com/huseyiniyibas/status/1388571724346793986
What I want to do is, when user taps any Training Exercises List should refreshed. It should be x5 again like in the beginning.
I had a hard time understanding your question but I guess I got the idea.
My understanding is this:
You want to store the rep count in the Core Data. (Under Training > Exercises)
You want to count down the reps one by one as the user completes the exercise.
But you don't want to change the original rep count stored in the Core Data.
I didn't run your code since I didn't want to recreate all the models and Core Data files. I guess I've spotted the problem. Here I'll explain how you can solve it:
The Core Data models are classes (reference types). When you pass around the classes (as you do in your code) and change their properties, you change the original data. In your case, you don't want that.
(Btw, being a reference type is a very useful and powerful property of classes. Structs and enums are value types, i.e. they are copied when passed around. The original data is unchanged.)
You have several options to solve your problem:
Just generate a different struct (something like ExerciseDisplay) from Exercise, and pass ExerciseDisplay to TrainingView.
You can write an extension to Exercise and "copy" the model before passing it to TrainingView. For this you'll need to implement the NSCopying protocol.
extension Exercise: NSCopying {
func copy(with zone: NSZone? = nil) -> Any {
return Exercise(...)
}
}
But before doing this I guess you'll need to change the Codegen to Manual/None of your entry in your .xcdatamodeld file. This is needed when you want to create the attributes manually. I'm not exactly sure how you can implement NSCopying for a CoreDate model, but it's certainly doable.
The first approach is easier but kinda ugly. The second is more versatile and elegant, but it's also more advanced. Just try the first approach first and move to the second once you feel confident.
Update:
This is briefly how you can implement the 1st approach:
struct ExerciseDisplay: Identifiable, Equatable {
public let id = UUID()
public let name: String
public var rep: Int
public let rest: Int
}
struct TrainingView: View {
// Other properties and states etc.
let training: Training
#State var exercises: [ExerciseDisplay] = []
init(training: Training) {
self.training = training
}
var body: some View {
VStack {
// Views
}
.onAppear() {
let stored: [Exercise] = training.exercises?.toArray() ?? []
self.exercises = stored.map { ExerciseDisplay(name: $0.name ?? "", rep: Int($0.rep), rest: Int($0.rest)) }
}
}
}
I want to extract String value from Observed Object
This is example code
import SwiftUI
import Combine
class SetViewModel : ObservableObject {
private static let userDefaultTextKey = "textKey"
#Published var text: String = UserDefaults.standard.string(forKey: SetViewModel.userDefaultTextKey) ?? ""
private var canc: AnyCancellable!
init() {
canc = $text.debounce(for: 0.2, scheduler: DispatchQueue.main).sink { newText in
UserDefaults.standard.set(newText, forKey: SetViewModel.userDefaultTextKey)
}
}
deinit {
canc.cancel()
}
}
struct SettingView: View {
#ObservedObject var viewModel = SettingViewModel()
var body: some View {
ZStack {
Rectangle().foregroundColor(Color.white).edgesIgnoringSafeArea(.all).background(Color.white)
VStack {
TextField("test", text: $viewModel.text).textFieldStyle(BottomLineTextFieldStyle()).foregroundColor(.red)
Text($viewModel.text) //I want to get String Value from $viewModel.text
}
}
}
}
I want to use "$viewModel.text"'s String value. How can I do this?
Here is fix
Text(viewModel.text) // << use directly, no $ needed, it is for binding
try this:
struct SettingView: View {
#ObservedObject var viewModel = SetViewModel()
var body: some View {
ZStack {
Rectangle().foregroundColor(Color.white).edgesIgnoringSafeArea(.all).background(Color.white)
VStack {
TextField("test", text: self.$viewModel.text)
.textFieldStyle(PlainTextFieldStyle())
.foregroundColor(.red)
Text(viewModel.text) //I want to get String Value from $viewModel.text
}
}
}
}
So I have a list that changes when user fill in search keyword, and when there is no result, all the cells collapse and somehow they would fly over to the first section which looks ugly. Is there an error in my code or is this an expected SwiftUI behavior? Thanks.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel(photoLibraryService: PhotoLibraryService.shared)
var body: some View {
NavigationView {
List {
Section {
TextField("Enter Album Name", text: $viewModel.searchText)
}
Section {
if viewModel.libraryAlbums.count > 0 {
ForEach(viewModel.libraryAlbums) { libraryAlbum -> Text in
let title = libraryAlbum.assetCollection.localizedTitle ?? "Album"
return Text(title)
}
}
}
}.listStyle(GroupedListStyle())
.navigationBarTitle(
Text("Albums")
).navigationBarItems(trailing: Button("Add Album", action: {
PhotoLibraryService.shared.createAlbum(withTitle: "New Album \(Int.random(in: 1...100))")
}))
}.animation(.default)
}
}
1) you have to use some debouncing to reduce the needs to refresh the list, while typing in the search field
2) disable animation of rows
The second is the hardest part. the trick is to force recreate some View by setting its id.
Here is code of simple app (to be able to test this ideas)
import SwiftUI
import Combine
class Model: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
#Published var data = ["art", "audience", "association", "attitude", "ambition", "assistance", "awareness", "apartment", "artisan", "airport", "atmosphere", "actor", "army", "attention", "agreement", "application", "agency", "article", "affair", "apple", "argument", "analysis", "appearance", "assumption", "arrival", "assistant", "addition", "accident", "appointment", "advice", "ability", "alcohol", "anxiety", "ad", "activity"].map(DataRow.init)
var filtered: [DataRow] {
data.filter { (row) -> Bool in
row.txt.lowercased().hasPrefix(debouncedText.lowercased())
}
}
var id: UUID {
UUID()
}
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct DataRow: Identifiable {
let id = UUID()
let txt: String
init(_ txt: String) {
self.txt = txt
}
}
struct ContentView: View {
#ObservedObject var search = Model(delay: 0.5)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
TextField("filter", text: $search.text)
.padding(.vertical)
.padding(.horizontal)
List(search.filtered) { (e) in
Text(e.txt)
}.id(search.id)
}.navigationBarTitle("Navigation")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and i am happy with the result