SwiftUI insert, delete, move and select with smooth animation - list

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

Related

Default navigation link in Swiftui

I'm working on an app the uses traditional sidebar navigation with a detail view. I've synthesized the app to illustrate two issues.
when the app starts, the detail view is empty. How can I programmatically select an entry in the sidebar to show in the detail view?
The sidebar allows swipe to delete. If the selected row (the one showing in the detail view) is deleted, it still shows in the detail view. How can update the detail view with, for example, an empty view?
Here's the source code for the app illustrating the issues:
import SwiftUI
class Model: ObservableObject {
var items = [Item("")]
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
class Item: Identifiable, Hashable {
static func == (lhs: Item, rhs: Item) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
let id = UUID()
#Published var name: String
init(_ name: String) {
self.name = name
}
}
#main
struct IBTSimulatorApp: App {
#StateObject var model = Model.loadData
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach($model.items, id: \.self) { $item in
NavigationLink(item.name, destination: Text(item.name))
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
}
private func addItem() {
withAnimation {
model.items.append(Item("New item (\(model.items.count))"))
model.objectWillChange.send()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
model.items.remove(atOffsets: offsets)
model.objectWillChange.send()
}
}
}
For 1. you can use the NavigationLink version with tag and selection, and save the active selection in a persisted AppStoragevar.
For 2. I expected you can set the selection to nil, but that does not work for some reason. But you can set it to the first item in the sidebar list.
As a general note you should make Item a struct instead of a class. Only the published Model should be a class.
class Model: ObservableObject {
var items: [Item] = []
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
struct Item: Identifiable { // Change from class to struct!
let id = UUID()
var name: String
init(_ name: String) {
self.name = name
}
}
struct ContentView: View {
#StateObject var model = Model.loadData
#AppStorage("selectemItem") var selected: String? // bind to persisted var here
var body: some View {
NavigationView {
List {
ForEach(model.items) { item in //no .id needed as Item is identifiable
NavigationLink(tag: item.id.uuidString, selection: $selected) { // use link with selection here
Text(item.name)
} label: {
Text(item.name)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Nothing selected")
}
}
private func addItem() {
withAnimation {
model.objectWillChange.send()
model.items.append(Item("New item (\(model.items.count))"))
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
// model.objectWillChange.send() // not necessary if Item is struct
self.selected = nil // for some reaseon this does not work
self.selected = model.items.first?.id.uuidString // selects first item
model.items.remove(atOffsets: offsets)
}
}
}

Swiftui remove swipe to delete functionality

Is there a way to remove or deactivate swipe to delete functionality that remove items only per edit button?
You can limit the delete functionality of a List/Form depending on the EditMode state, by using deleteDisabled(_:).
The following is a short example demonstrating deleting which only works in edit mode:
struct ContentView: View {
#State private var data = Array(1 ... 10)
var body: some View {
NavigationView {
Form {
DataRows(data: $data)
}
.navigationTitle("Delete Test")
.toolbar {
EditButton()
}
}
}
}
struct DataRows: View {
#Environment(\.editMode) private var editMode
#Binding private var data: [Int]
init(data: Binding<[Int]>) {
_data = data
}
var body: some View {
ForEach(data, id: \.self) { item in
Text("Item: \(item)")
}
.onMove { indices, newOffset in
data.move(fromOffsets: indices, toOffset: newOffset)
}
.onDelete { indexSet in
data.remove(atOffsets: indexSet)
}
.deleteDisabled(editMode?.wrappedValue != .active)
}
}

SwiftUI - Subclassed viewModel doesn't trigger view refresh

I have this situation where I have a a BaseView containing some common elements and a BaseViewModel containing some common functions, but when its #Published var get updated no BaseView refresh occurs.
The setup is this:
class BaseViewModel: ObservableObject {
#Published var overlayView: AnyView = EmptyView().convertToAnyView()
func forceViewRefresh() {
self.objectWillChange.send()
}
func setOverlayView(overlayView: AnyView) {
self.overlayView = overlayView
}
}
This view model subclasses BaseViewModel:
class FirstViewModel: BaseViewModel {
func showOverlayView() {
self.setOverlayView(overlayView: OverlayView().convertToAnyView())
}
}
also I have a BaseView where I use the overlayView
struct BaseView<Content: View>: View {
let content: Content
#ObservedObject var viewModel = BaseViewModel()
init(content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.vertical)
content
viewModel.overlayView
}
}
}
The first view that gets displayed is FirstView, which conforms to a BaseViewProtocol and has a FirstViewModel that extends BaseViewModel.
struct FirstView: BaseViewProtocol {
#ObservedObject var viewModel = FirstViewModel()
var body: some View {
BaseView() {
Button("Show overlay") {
viewModel.showOverlayView()
}
}
}
}
Clicking the Show overlay button in First View calls the showOverlayView() func on FirstViewModel which in turn calls setOverlayView on the BaseViewModel. The value of overlayView in BaseViewModel changes as expected, but no view refresh on FirstView is called.
What am I doing wrong?
Thanks a lot.
I have just tested this code sample and works fine on Xcode 12 beta 6 & iOS 14 beta 8
struct ContentView: View {
#StateObject private var viewModel = FirstViewModel()
var body: some View {
ZStack {
Button(action: { viewModel.showOverlayView() }) {
Text("Press")
}
viewModel.overlayView
}
}
}
class BaseViewModel: ObservableObject {
#Published var overlayView: AnyView = AnyView(EmptyView())
func forceViewRefresh() {
self.objectWillChange.send()
}
func setOverlayView(overlayView: AnyView) {
self.overlayView = overlayView
}
}
class FirstViewModel: BaseViewModel {
func showOverlayView() {
self.setOverlayView(
overlayView: AnyView(
Color.blue
.opacity(0.2)
.allowsHitTesting(false)
)
)
}
}
Generally in SwiftUI you don't create views in outside the body. The view creation should be left to SwiftUI - instead you can define some other controls telling SwiftUI how and when to create a view.
Here is a simplified demo how to present different overlays for different views.
You can create a basic OverlayView:
enum OverlayType {
case overlay1, overlay2
}
struct OverlayView: View {
let overlayType: OverlayType
#ViewBuilder
var body: some View {
if overlayType == .overlay1 {
Text("Overlay1") // can be replaced with any view you want
}
if overlayType == .overlay2 {
Text("Overlay1")
}
}
}
and use it in your BaseView (if overlayType is nil the overlay will not be shown):
struct BaseView<Content>: View where Content: View {
let overlayType: OverlayType?
let content: () -> Content
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.vertical)
content()
if overlayType != nil {
OverlayView(overlayType: overlayType!)
}
}
}
}
Now in the ContentView you can use the BaseView and specify its OverlayType.
struct ContentView: View {
#State var overlayType: OverlayType?
var body: some View {
BaseView(overlayType: overlayType) {
Button("Show overlay") {
overlayType = .overlay1
}
}
}
}
Some considerations:
For simplicity I used #State variables to control overlays. If there are other use cases for your ViewModels you may want to move the logic there.
Note that instead of AnyView it's preferred to use #ViewBuilder.
Also if you want to observe an ObservableObject inside a view, you need to use #ObservedObject, not #ObservableObject.

SwiftUI onDelete List with Toggle and NavigationLink

I refer to two questions that I already asked and have been answered very well by Asperi: SwiftUI ForEach with .indices() does not update after onDelete,
SwiftUI onDelete List with Toggle
Now I tried to modify the closure in ForEach with a NavigationLink and suddenly the App crashes again with
Thread 1: Fatal error: Index out of range
when I try to swipe-delete.
Code:
class Model: ObservableObject {
#Published var name: String
#Published var items: [Item]
init(name: String, items: [Item]) {
self.name = name
self.items = items
}
}
struct Item: Identifiable {
var id = UUID()
var isOn: Bool
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach(model.items) {item in
NavigationLink(destination: DetailView(item: self.makeBinding(id: item.id))) {
Toggle(isOn: self.makeBinding(id: item.id).isOn)
{Text("Toggle-Text")}
}
}.onDelete(perform: delete)
}
}
}
func delete(at offsets: IndexSet) {
self.model.items.remove(atOffsets: offsets)
}
func makeBinding(id: UUID) -> Binding<Item> {
guard let index = self.model.items.firstIndex(where: {$0.id == id}) else {
fatalError("This person does not exist")
}
return Binding(get: {self.model.items[index]}, set: {self.model.items[index] = $0})
}
}
struct DetailView: View {
#Binding var item: Item
var body: some View {
Toggle(isOn: $item.isOn) {
Text("Toggle-Text")
}
}
}
It works without NavigationLink OR without the Toggle. So it seems for me that I only can use the makeBinding-Function once in this closure.
Thanks for help
Your code was crashing for me with and even without Navigation Link. Sometimes only if I deleted the last object in the Array. It looks like it was still trying to access an index out of the array. The difference to your example you linked above, is that they didn't used EnvironmentObject to access the array. The stored the array directly in the #State.
I came up with a little different approach, by declaring Item as ObservedObject and then simply pass it to the subview where you can use their values as Binding, without any function.
I changed Item to..
class Item: ObservableObject {
var id = UUID()
var isOn: Bool
init(id: UUID, isOn: Bool)
{
self.id = id
self.isOn = isOn
}
}
Change the ContentView to this..
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach(model.items, id:\.id) {item in
NavigationLink(destination: DetailView(item: item)) {
Toggler(item: item)
}
}.onDelete(perform: delete)
}
}
}
I outsourced the Toggle to a different view, where we pass the ObservedObject to, same for the DetailView.
struct Toggler: View {
#ObservedObject var item : Item
var body : some View
{
Toggle(isOn: $item.isOn)
{Text("Toggle-Text")}
}
}
struct DetailView: View {
#ObservedObject var item: Item
var body: some View {
Toggle(isOn: $item.isOn) {
Text("Toggle-Text")
}
}
}
They both take an Item as ObservedObject and use it as Binding for the Toggle.

How to add an observable property when other properties change

I have the following model object that I use to populate a List with a Toggle for each row, which is bound to measurement.isSelected
final class Model: ObservableObject {
struct Measurement: Identifiable {
var id = UUID()
let name: String
var isSelected: Binding<Bool>
var selected: Bool = false
init(name: String) {
self.name = name
let selected = CurrentValueSubject<Bool, Never>(false)
self.isSelected = Binding<Bool>(get: { selected.value }, set: { selected.value = $0 })
}
}
#Published var measurements: [Measurement]
#Published var hasSelection: Bool = false // How to set this?
init(measurements: [Measurement]) {
self.measurements = measurements
}
}
I'd like the hasSelection property to be true whenever any measurement.isSelected is true. I'm guessing somehow Model needs to observe changes in measurements and then update its hasSelection property… but I've no idea where to start!
The idea is that hasSelection will be bound to a Button to enable or disable it.
Model is used as follows…
struct MeasurementsView: View {
#ObservedObject var model: Model
var body: some View {
NavigationView {
List(model.measurements) { measurement in
MeasurementView(measurement: measurement)
}
.navigationBarTitle("Select Measurements")
.navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: $model.hasSelection, label: {
Text("Next")
}))
}
}
}
struct MeasurementView: View {
let measurement: Model.Measurement
var body: some View {
HStack {
Text(measurement.name)
.font(.subheadline)
Spacer()
Toggle(measurement.name, isOn: measurement.isSelected)
.labelsHidden()
}
}
}
For info, here's a screenshot of what I'm trying to achieve. A list of selectable items, with a navigation link that is enabled when one or more is selected, and disabled when no items are selected.
#user3441734 hasSelection should ideally be a get only property, that
is true if any of measurement.isSelected is true
struct Data {
var bool: Bool
}
class Model: ObservableObject {
#Published var arr: [Data] = []
var anyTrue: Bool {
arr.map{$0.bool}.contains(true)
}
}
example (as before) copy - paste - run
import SwiftUI
struct Data: Identifiable {
let id = UUID()
var name: String
var on_off: Bool
}
class Model: ObservableObject {
#Published var data = [Data(name: "alfa", on_off: false), Data(name: "beta", on_off: false), Data(name: "gama", on_off: false)]
var bool: Bool {
data.map {$0.on_off} .contains(true)
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
List(0 ..< model.data.count) { idx in
HStack {
Text(verbatim: self.model.data[idx].name)
Toggle(isOn: self.$model.data[idx].on_off) {
EmptyView()
}
}
}
Text("\(model.bool.description)").font(.largeTitle).padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When the model.data is updated
#Published var data ....
its publisher calls objectWillChange on ObservableObject.
Next SwiftUI recognize that ObservedObject needs the View to be "updated". The View is recreated, and that will force the model.bool.description will have fresh value.
LAST UPDATE
change this part of code
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
NavigationView {
List(0 ..< model.data.count) { idx in
HStack {
Text(verbatim: self.model.data[idx].name)
Toggle(isOn: self.$model.data[idx].on_off) {
EmptyView()
}
}
}.navigationBarTitle("List")
.navigationBarItems(trailing:
NavigationLink(destination: Text("next"), label: {
Text("Next")
}).disabled(!model.bool)
)
}
}
}
and it is EXACTLY, WHAT YOU HAVE in your updated question
Try it on real device, otherwise the NavigationLink is usable only once (this is well known simulator bug in current Xcode 11.3.1 (11C504)).
The problem with your code at the moment is that even if you observe the changes to measurements, they will not get updated when the selection updates, because you declared the var isSelected: Binding<Bool> as a Binding. This means that SwiftUI is storing it outside of your struct, and the struct itself doesn't update (stays immutable).
What you could try instead is declaring #Published var selectedMeasurementId: UUID? = nil on your model So your code would be something like this:
import SwiftUI
import Combine
struct NextView: View {
var body: some View {
Text("Next View")
}
}
struct MeasurementsView: View {
#ObservedObject var model: Model
var body: some View {
let hasSelection = Binding<Bool> (
get: {
self.model.selectedMeasurementId != nil
},
set: { value in
self.model.selectedMeasurementId = nil
}
)
return NavigationView {
List(model.measurements) { measurement in
MeasurementView(measurement: measurement, selectedMeasurementId: self.$model.selectedMeasurementId)
}
.navigationBarTitle("Select Measurements")
.navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: hasSelection, label: {
Text("Next")
}))
}
}
}
struct MeasurementView: View {
let measurement: Model.Measurement
#Binding var selectedMeasurementId: UUID?
var body: some View {
let isSelected = Binding<Bool>(
get: {
self.selectedMeasurementId == self.measurement.id
},
set: { value in
if value {
self.selectedMeasurementId = self.measurement.id
} else {
self.selectedMeasurementId = nil
}
}
)
return HStack {
Text(measurement.name)
.font(.subheadline)
Spacer()
Toggle(measurement.name, isOn: isSelected)
.labelsHidden()
}
}
}
final class Model: ObservableObject {
#Published var selectedMeasurementId: UUID? = nil
struct Measurement: Identifiable {
var id = UUID()
let name: String
init(name: String) {
self.name = name
}
}
#Published var measurements: [Measurement]
init(measurements: [Measurement]) {
self.measurements = measurements
}
}
I'm not sure exactly how you want the navigation button in the navbar to behave. For now I just set the selection to nil when it's tapped. You can modify it depending on what you want to do.
If you want to support multi-selection, you can use a Set of selected ids instead.
Also, seems like the iOS simulator has some problems with navigation, but I tested on a physical device and it worked.