SwiftUI how to delete from ForEach by ID, not index - swiftui

The following code makes me uncomfortable.
struct HistoryView: View {
#ObservedObject var history: History
var body: some View {
List {
ForEach(history.getSessions()) { sess in
Text("Duration: \(sess.duration)")
}.onDelete(perform: self.onDelete)
}
}
private func onDelete(_ indexSet: IndexSet) {
...
}
}
The problem is that History is a very asynchronous thing. It's a model connected to CloudKit. It could be getting updates in the background. Ideally, if it updates, this View would update immediately and the indexes would still be accurate, but I much prefer to get a set of the identifiers to delete, not set of indexes. Is there a way to do this? Note: those "history sessions" conform to Identifiable as required by ForEach, so they all have IDs. In my case they are UUIDs.

maybe this could help lock history sessions while you delete the index you selected:
struct HistoryView: View {
#ObservedObject var history: History
private let syncQueue = DispatchQueue(label: "com.xxx.yyy.zzz")
var body: some View {
List {
ForEach(history.getSessions()) { sess in
Text("Duration: \(sess.duration)")
}.onDelete(perform: self.onDelete)
}
}
private func onDelete(_ indexSet: IndexSet) {
syncQueue.sync {
// do the delete
}
}
}

Related

SwiftUI insert, delete, move and select with smooth animation

I am planning to implement following features in the SwiftUI list - delete, insert, move and select.
With the existing list I am able to delete a row. But can't select a row does not work with List(selection: self.$selectedObject). When I hit edit it always enters into delete mode. And I comment the delete code nothing happens when I tap on edit button. This the first problem.
Also, selectedObject can it be moved to Model instead of keeping it with the ContentView?
Like UITableView, I am not able to get the insert green button. Is it like SwiftUI does not support the green insert button?
Overall trying to understand how the insert, delete, move and select functionality can work with the List SwiftUI.
Another problem I have noticed is that animation is very fast and not smooth when it enters into edit mode (with delete actions).
struct ContentView: View {
#StateObject private var model = Model()
#State var selectedObject: Locations?
var body: some View {
NavigationView {
List(selection: self.$selectedObject) {
ForEach(model.identifiableLocations) { location in
Text(location.name)
}
.onDelete(perform: delete(of:))
}.listStyle(.plain)
.navigationTitle("Places")
.toolbar {
EditButton()
Button {
model.addLocation(name: "Test")
} label: {
Image(systemName: "plus")
}
}
}
}
func delete(of indexSet: IndexSet){
indexSet.forEach { index in
model.delete(itemAt: index)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().previewDevice(PreviewDevice(rawValue: "iPhone 14"))
}
}
extension ContentView {
#MainActor class Model: ObservableObject {
#Published private(set) var identifiableLocations = [Locations(name: "USA"),
Locations(name: "Switzerland")]
}
}
extension ContentView.Model {
func addLocation(name: String) {
identifiableLocations.append(Locations(name: name))
}
func delete(itemAt index: Int) {
identifiableLocations.remove(at: index)
}
}
struct Locations {
var name: String
}
extension Locations: Identifiable,Hashable {
var id: String {
return UUID().uuidString
}
}
to make selection work, the list cells need a .tag(). This value is going into the selection var.
yes, selectedObject can be moced to the view model as an additional #Published var
SwiftUI List does not have an insert method, but your Add Button already does that.
The animation is broke because your id in Location is not stable, but generated on each call by the computed var. id should be stable!
Here a running code with comments:
#MainActor
class ViewModel: ObservableObject {
#Published private(set) var identifiableLocations = [
Locations(name: "USA"),
Locations(name: "Switzerland")
]
// published selection var
#Published var selectedObject: Locations?
func addLocation(name: String) {
identifiableLocations.append(Locations(name: name))
}
func delete(itemAt index: Int) {
identifiableLocations.remove(at: index)
}
// new move func
func move(fromOffsets: IndexSet, toOffset: Int) -> Void {
identifiableLocations.move(fromOffsets: fromOffsets, toOffset: toOffset)
}
}
struct Locations: Identifiable, Hashable {
let id = UUID() // id has to stay stable
// var id: String {
// return UUID().uuidString
// }
var name: String
}
struct ContentView: View {
#StateObject private var viewModel = ViewModel()
// #State var selectedObject: Locations? // is now in viewmodel
var body: some View {
NavigationView {
List(selection: $viewModel.selectedObject) {
ForEach(viewModel.identifiableLocations) { location in
Text(location.name)
.tag(location) // this makes selction work
}
.onDelete(perform: delete(of:))
.onMove(perform: viewModel.move)
}
.listStyle(.plain)
.navigationTitle("Places")
.toolbar {
EditButton()
Button {
viewModel.addLocation(name: "Test")
} label: {
Image(systemName: "plus")
}
}
}
}
func delete(of indexSet: IndexSet){
indexSet.forEach { index in
self.viewModel.delete(itemAt: index)
}
}
}

SwiftUI #State variables aren't updated

I basically have the same code as in this question. The problem I have is that when the tapGesture event happens, the sheet shows (the sheet code is called) but debug shows that showUserEditor is false (in that case, how is the sheet showing...?) and that selectedUserId is still nil (and therefore crashes on unwrapping it...)
The view:
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUserId : NSManagedObjectID? = nil
#State private var showUserEditor = false
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUserId = user.objectID
self.showUserEditor = true
}
}
}
}.sheet(isPresented: $showUserEditor) {
UserEditorView(userId: self.selectedUserId!)
}
}
}
If you want, I can publish the editor and the row but they seem irrelevant to the question as the magic should happen in the view.
So, I still haven't figured out WHY the code posted in the question didn't work, with a pointer from #loremipsum I got a working code by using another .sheet() method, one that takes an optional Item and not a boolean flag. The code now looks like this and works, but still if anyone can explain why the posted code didn't work I'd appreciate it.
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}.onDelete(perform: deleteItems)
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in
UserEditorView(user: user)
}
}
}
struct == immutable and SwiftUI decides when the struct gets init and reloaded
Working with code that depends on SwiftUI updating non-wrapped variables at a very specific time is not recommended. You have no control over this process.
To make your first setup work you need to use SwiftUI wrappers for the variables
.sheet(isPresented: $showUserEditor) {
//struct == immutable SwiftUI wrappers load the entire struct when there are changes
//With your original setup this variable gets created/set when the body is loaded so the orginal value of nil is what is seen in the next View
UserEditorView1(userId: $selectedUserId)
}
struct UserEditorView1: View {
//This is what you orginal View likely looks like it won't work because of the struct being immutable and SwiftUI controlling when the struct is reloaded
//let userId: NSManagedObjectID? <---- Depends on specific reload steps
//To make it work you would use a SwiftUI wrapper so the variable gets updated when SwiftUI descides to update it which is invisible to the user
#Binding var userId: NSManagedObjectID?
//This setup though now requres you to go fetch the object somehow and put it into the View so you can edit it.
//It is unnecessary though because SwiftUI provides the .sheet init with item where the item that is set gets passed directly vs waiting for the SwiftUi update no optionals
var body: some View {
Text(userId?.description ?? "nil userId")
}
}
Your answer code doesn't work because your parameter is optional and Binding does not like optionals
struct UserEditorView2: View {
//This is the setup that you posted in the Answer code and it doesn't work becaue of the ? Bindings do not like nil. You have to create wrappers to compensate for this
//But unecessary because all CoreData objects are ObservableObjects so you dont need Binding here the Binding is built-in the object for editing the variables
#Binding var user: User?
var body: some View {
TextField("nickname", text: $user.nickname)
}
}
Now for working code with an easily editable CoreData Object
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
//Your list view would use the CoreData object to trigger a sheet when the new value is available. When nil there will not be a sheet available for showing
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in //This gives you a non-optional user so you don't have to compensate for nil in the next View
UserEditorView3(user: user)
}
}
}
Then the View in the sheet would look like this
struct UserEditorView3: View {
//I mentioned the ObservedObject in my comment
#ObservedObject var user: User
var body: some View {
//If your nickname is a String? you have to compensate for that optional but it is much simpler to do it from here
TextField("nickname", text: $user.nickname.bound)
}
}
//This comes from another very popular SO question (couldn't find it to quote it) that I could not find and is necessary when CoreData does not let you define a variable as non-optional and you want to use Binding for editing
extension Optional where Wrapped == String {
var _bound: String? {
get {
return self
}
set {
self = newValue
}
}
public var bound: String {
get {
//This just give you an empty String when the variable is nil
return _bound ?? ""
}
set {
_bound = newValue.isEmpty ? nil : newValue
}
}
}

Capturing UndoManager from SwiftUI environment

I want to be able to access the UndoManager from inside my document model, so I can register undo actions from within the model:
// Assume I've extended MyDocument to conform to ReferenceFileDocument elsewhere...
final class MyDocument {
private var undoManager: UndoManager?
#Published var aNumber = 5 {
willSet {
if let undoManager = undoManager {
let currentValue = self.aNumber
undoManager.registerUndo(withTarget: self) { target in
target.aNumber = currentValue
}
}
}
}
func setUndoManager(undoManager: UndoManager?) {
self.undoManager = undoManager
}
}
To be register the undoManager, I have tried this:
struct DocumentView: View {
let document : MyDocument
#Environment(\.undoManager) var undoManager
var body: some View {
MyDocumentEditor(document: document)
.onAppear {
document.setUndoManager(undoManager: undoManager)
}
}
}
When running my app and loading a saved document this works. But when starting from a new document the UndoManager is nil.
I've tried things like:
#Environment(\.undoManager) var undoManager {
didSet {
self.document.setUndoManager(undoManager: undoManager)
}
}
My objective here is to try and keep as much logic in the model and the views focusing only on UI stuff as much as possible. I wish that ReferenceFileDocument gave a property to access its associated UndoManager as is available with NSDocument.
It looks more natural for SwiftUI to use the following approach
var body: some View {
TopLevelView(document: document, undoManager: undoManager)
}
and
struct TopLevelView: View {
#ObservedObject var document : MyDocument
var undoManager: UndoManager?
init(document: MyDocument, undoManager: UndoManager?) {
self.document = document
self.undoManager = undoManager
self.setUndoManager(undoManager: undoManager)
}
// ... other code
}
I have found a solution to this - although it doesn't feel right. At the top level of the View I pass the undoManager to a property I hold on the document:
struct ContentView: View {
let document: MyDocument
#Environment(\.undoManager) var undoManager
var body: some View {
document.setUndoManager(undoManager: undoManager)
return TopLevelView(document: document)
}
}
After trying to figure this out for more than a day, my takeaway is that the UndoManager in the Environment is the one attached to the NSWindow where the view lives. My solution is:
protocol Undoable {
func inverted() -> Self
}
class Store<State, Action : Undoable> {
var state : State
var reducer : (inout State, Action) -> Void
//...init...
func send(_ action: Action, undoManager: UndoManager) {//passed as an argument
reducer(&state, action)
undoManager.registerUndo(withTarget: self){target in
target.send(action.inverted())
}
}
//...other methods...
}
Store can of course be your document class. Now you can pass the UndoManager found in the environment from any view that sends actions (pay attentions to sheets and alerts though). Or you automate that step away:
class Dispatcher<State, Action : Undoable> : ObservableObject {
let store : Store<State, Action>
let undoManager : UndoManager //see below
//...init...
func send(_ action: Action) {
objectWillChange.send()
store.send(action, undoManager: undoManager)
}
}
struct ContentView<State, Action : Undoable> : View {
#Environment(\.undoManager) var undoManager
let document : Store<State, Action>
var body : some View {
ViewHierarchy().environmentObject(Dispatcher(store: document,
undoManager: undoManager)
}
}
(maybe you'd need to put the Dispatcher into a StateObject, I didn't test that part because I'm happy passing the undo manager as a function argument in my small app).

How to deinit ViewModel with combine framework when views disappears

I use Combine in viewModels to update the views. But if I store the AnyCancellable objects into a set of AnyCancellable, the deinit method is never called. I use the deinit to cancel all cancellables objects.
struct View1: View {
#ObservedObject var viewModel:ViewTextModel = ViewTextModel()
#Injected var appActions:AppActions
var body: some View {
VStack {
Text(self.viewModel.viewText)
Button(action: {
self.appActions.goToView2()
}) {
Text("Go to view \(self.viewModel.viewText)")
}
}
}
}
class ViewTextModel: ObservableObject {
#Published var viewText: String
private var cancellables = Set<AnyCancellable>()
init(state:AppState) {
// initial state
viewText = "view \(state.view)"
// updated state
state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
}
deinit {
cancellables.forEach { $0.cancel() }
}
}
Each time the view is rebuilt, a new viewmodel is instantiated but the old one is not destroyed. viewText attribute is updated on each instance with state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
If I don't store the cancellable object in the set, deinit is called but viewText is not updated if the state's changed for the current view.
Do you have an idea of ​​how to manage the update of the state without multiplying the instances of the viewmodel ?
Thanks
You could use sink instead of assign:
state.$view
.removeDuplicates()
.sink { [weak self] in self?.viewText = $0 }
.store(in: &cancellables)
But I question the need for Combine here at all. Just use a computed property:
class ViewTextModel: ObservableObject {
#Published var state: AppState
var viewText: String { "view \(state.view)" }
}
UPDATE
If your deployment target is iOS 14 (or macOS 11) or later:
Because you are storing to an #Published, you can use the assign(to:) operator instead. It manages the subscription for you without returning an AnyCancellable.
state.$view
.removeDuplicates()
.map { "view \($0)" }
.assign(to: &$viewText)
// returns Void, so nothing to store

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