How to edit a list of subclass objects - swiftui

I have a list of items of different classes derived from the same class.
The goal: editing any object using a different view
The model:
class Paper: Hashable, Equatable {
var name: String
var length: Int
init() {
name = ""
length = 0
}
init(name: String, length: Int) {
self.name = name
self.length = length
}
static func == (lhs: Paper, rhs: Paper) -> Bool {
return lhs.length == rhs.length
}
func hash(into hasher: inout Hasher) {
hasher.combine(length)
}
}
class ScientificPaper: Paper {
var biology: Bool
override init(name: String, length: Int) {
biology = false
super.init(name: name, length: length)
}
}
class TechnicalPaper: Paper {
var electronics: Bool
override init(name: String, length: Int) {
electronics = false
super.init(name: name, length: length)
}
}
The main view containing the list.
struct TestView: View {
#Binding var papers: [Paper]
#State private var edit = false
#State private var selectedPaper = Paper()
var body: some View {
let scientificBinding = Binding<ScientificPaper>(
get: {selectedPaper as! ScientificPaper},
set: { selectedPaper = $0 }
)
VStack {
List {
ForEach(papers, id: \.self) { paper in
HStack {
Text(paper.name)
Text("\(paper.length)")
Spacer()
Button("Edit") {
selectedPaper = paper
edit = true
}
}
}
}
}
.sheet(isPresented: $edit) {
VStack {
if selectedPaper is ScientificPaper {
ScientificForm(paper: scientificBinding)
}
if selectedPaper is TechnicalPaper {
TechnicalForm(paper: technicalBinding)
}
}
}
}
}
The custom view for each class.
struct ScientificForm: View {
#Binding var paper: ScientificPaper
var body: some View {
Form {
Text("Scientific")
TextField("Name: ", text: $paper.name)
TextField("Length: ", value: $paper.length, formatter: NumberFormatter())
TextField("Biology: ", value: $paper.biology, formatter: NumberFormatter())
}
}
}
struct TechnicalForm: View {
#Binding var paper: TechnicalPaper
var body: some View {
Form {
Text("Technical")
TextField("Name: ", text: $paper.name)
TextField("Length: ", value: $paper.length, formatter: NumberFormatter())
TextField("Electronics: ", value: $paper.electronics, formatter: NumberFormatter())
}
}
}
Problem is that at run time I get the following:
Could not cast value of type 'Paper' to 'ScientificPaper'.
maybe because the selectedPaper is already initialized as Paper.
What is the right strategy to edit list items belonging to different classes?

The error is due to creating binding in body, which calculates on every refresh, so binding is invalid.
The solution is to make binding as computable property, so it is requested only after validation in correct flow.
Tested with Xcode 12.1 / iOS 14.1 (demo is for scientificBinding only for simplicity)
struct TestView: View {
#Binding var papers: [Paper]
#State private var edit = false
#State private var selectedPaper = Paper()
var scientificBinding: Binding<ScientificPaper> { // << here !!
return Binding<ScientificPaper>(
get: {selectedPaper as! ScientificPaper},
set: { selectedPaper = $0 }
)
}
var body: some View {
VStack {
List {
ForEach(papers, id: \.self) { paper in
HStack {
Text(paper.name)
Text("\(paper.length)")
Spacer()
Button("Edit") {
selectedPaper = paper
edit = true
}
}
}
}
}
.sheet(isPresented: $edit) {
VStack {
if selectedPaper is ScientificPaper {
ScientificForm(paper: scientificBinding)
}
// if selectedPaper is TechnicalPaper {
// TechnicalForm(paper: technicalBinding)
// }
}
}
}
}

Related

Why is the navigationDestination with the binding not updating properly?

Why is the binding not updating the array and the PatientView title in the ContentView?
I use the ForEach that takes a binding and the navigationDestination then passes that binding to the child view.
import SwiftUI
struct PatientView: View {
#Binding var patient: Patient
var body: some View {
Section("Name") {
TextField("Name", text: $patient.name)
.textFieldStyle(.roundedBorder)
}
Spacer()
.navigationTitle(patient.name)
}
}
protocol BlankInit {
init()
}
extension View {
func binding<V>(id: V.ID, array: Binding<[V]>) -> Binding<V> where V: Identifiable {
if let ret = array.first(where: { $0.id == id}) {
return ret
} else {
fatalError()
}
}
}
extension Binding : Hashable, Equatable where Value: Identifiable, Value: Equatable, Value: Hashable {
public static func == (lhs: Binding<Value>, rhs: Binding<Value>) -> Bool {
lhs.wrappedValue == rhs.wrappedValue
}
public func hash(into hasher: inout Hasher) {
hasher.combine("Binding")
hasher.combine(self.wrappedValue)
}
}
struct Patient: Hashable, Equatable, Identifiable, BlankInit {
var id: UUID = UUID()
var name: String = ""
var age: UInt8 = 0
init(_ name: String, age: UInt8 = 0) {
self.name = name
self.age = 0
self.id = UUID()
}
init() {
self.name = ""
self.age = 0
self.id = UUID()
}
static func == (lhs: Patient, rhs: Patient) -> Bool {
lhs.id == rhs.id && lhs.name == rhs.name && lhs.age == rhs.age
}
}
struct ContentView: View {
#StateObject var appData: AppData = AppData()
var body: some View {
NavigationStack(path: $appData.path) {
List {
ForEach($appData.patients) { $pt in
NavigationLink(pt.name, value: $pt)
}
}
.navigationDestination(for: Binding<Patient>.self) { item in
PatientView(patient: item)
}
.navigationTitle("Patients")
}
.environmentObject(appData)
.padding()
}
}
class AppData: ObservableObject {
#Published var path = NavigationPath()
#Published var patients = [Patient]()
init() {
patients.append(Patient("Smith"))
patients.append(Patient("Jones"))
patients.append(Patient("DeCaro"))
}
}
#main
struct TestAppApp: App {
var body: some Scene {
WindowGroup {
ContentView() //.environmentObject(AppData())
}
}
}
It works fine if I do the following:
struct ContentView: View {
#StateObject var appData: AppData = AppData()
var body: some View {
NavigationStack(path: $appData.path) {
List {
ForEach(appData.patients) { pt in
NavigationLink(pt.name, value: pt)
}
}
.navigationDestination(for: Patient.self) { item in
let binding = binding(id: item.id, array: $appData.patients)
PatientView(patient: binding)
}
.navigationTitle("Patients")
}
.environmentObject(appData)
.padding()
}
}
It seems more 'elegant' and less kludgy to be able to do the Binding version????

SwiftUI: Textfield shake animation when input is not valid

I want to create a shake animation when the User presses the "save"-button and the input is not valid. My first approach is this (to simplify I removed the modifiers and not for this case relevant attributes):
View:
struct CreateDeckView: View {
#StateObject var viewModel = CreateDeckViewModel()
HStack {
TextField("Enter title", text: $viewModel.title)
.offset(x: viewModel.isValid ? 0 : 10) //
.animation(Animation.default.repeatCount(5).speed(4)) // shake animation
Button(action: {
viewModel.buttonPressed = true
viewModel.saveDeck(){
self.presentationMode.wrappedValue.dismiss()
}
}, label: {
Text("Save")
})
}
}
ViewModel:
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var buttonPressed = false
var validTitle: Bool {
buttonPressed && !(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){ ... }
}
But this solution doesn't really work. For the first time when I press the button nothing happens. After that when I change the textfield it starts to shake.
using GeometryEffect,
struct ContentView: View {
#StateObject var viewModel = CreateDeckViewModel()
var body: some View {
HStack {
TextField("Enter title", text: $viewModel.title)
.modifier(ShakeEffect(shakes: viewModel.shouldShake ? 2 : 0)) //<- here
.animation(Animation.default.repeatCount(6).speed(3))
Button(action: {
viewModel.saveDeck(){
...
}
}, label: {
Text("Save")
})
}
}
}
//here
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
}
init(shakes: Int) {
position = CGFloat(shakes)
}
var position: CGFloat
var animatableData: CGFloat {
get { position }
set { position = newValue }
}
}
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var shouldShake = false
var validTitle: Bool {
!(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){
if !validTitle {
shouldShake.toggle() //<- here (you can use PassThrough subject insteadof toggling.)
}
}
}

Why .append doesn't reload swiftUI view

I have the following view hierarchy
Nurse List View > Nurse Card > Favorite button
Nurse List View
struct NurseListView: View {
#State var data: [Nurse] = []
var body: some View {
List {
ForEach(data.indices, id: \.self) { index in
NurseCard(data: self.$data[index])
}
}
}
}
Nurse Card
struct NurseCard: View {
#Binding var data: Nurse
var body: some View {
FavoriteActionView(data:
Binding(
get: { self.data },
set: { self.data = $0 as! Nurse }
)
)
}
}
Favorite Action View
struct FavoriteActionView: View {
#Binding var data: FavoritableData
var body: some View {
Button(action: {
self.toggleFavIcon()
}) {
VStack {
Image(data.isFavorite ? "fav-icon" : "not-fav-icon")
Text(String(data.likes.count))
}
}
}
private func toggleFavIcon() {
if data.isFavorite {
if let index = data.likes.firstIndex(of: AppState.currentUser.uid) {
data.likes.remove(at: index)
}
} else {
data.likes.append(AppState.currentUser.uid)
}
}
}
When toggleFavIcon execute, it append/remove the user id from the likes property in data object but I can't see the change unless I go back to previous page and reopen the page. What I am missing here?
As Asperi wrote, using an ObservableObject would work well here. Something like this:
class FavoritableData: ObservableObject {
#Published var likes: [String] = []
#Published var isFavorite = false
}
struct FavoriteActionView: View {
#ObservedObject var data: FavoritableData
var body: some View {
Button(action: {
self.toggleFavIcon()
}) {
VStack {
Image(data.isFavorite ? "fav-icon" : "not-fav-icon")
Text(String(data.likes.count))
}
}
}
private func toggleFavIcon() {
if data.isFavorite {
if let index = data.likes.firstIndex(of: AppState.currentUser.uid) {
data.likes.remove(at: index)
}
} else {
data.likes.append(AppState.currentUser.uid)
}
}
}

Refreshing NavigationView Environment Object

Im trying to create an environment object that is editable and putting it in a list.
The Variables are only refreshing when I switch the tab for example (so whenever I leave the NavigationView) and then come back.
The same worked with a ModalView before. Is it a bug maybe? Or am I doing something wrong?
import SwiftUI
import Combine
struct TestView: View {
#State var showSheet: Bool = false
#EnvironmentObject var feed: TestObject
func addObjects() {
var strings = ["one","two","three","four","five","six"]
for s in strings {
var testItem = TestItem(text: s)
self.feed.items.append(testItem)
}
}
var body: some View {
TabView {
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: detailView(feed: self._feed, i: i)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}
}
}
}
.tabItem({ Text("Test") })
.tag(0)
Text("Blank")
.tabItem({ Text("Test") })
.tag(0)
}.onAppear {
self.addObjects()
}
}
}
struct detailView: View {
#EnvironmentObject var feed: TestObject
var i: Int
var body: some View {
VStack {
Text(feed.items[i].text)
Text(feed.items[i].read.description)
Button(action: { self.feed.items[self.i].isRead.toggle() }) {
Text("Toggle read")
}
}
}
}
final class TestItem: ObservableObject {
init(text: String) {
self.text = text
self.isRead = false
}
static func == (lhs: TestItem, rhs: TestItem) -> Bool {
lhs.text < rhs.text
}
var text: String
var isRead: Bool
let willChange = PassthroughSubject<TestItem, Never>()
var read: Bool {
set {
self.isRead = newValue
}
get {
self.isRead
}
}
}
class TestObject: ObservableObject {
var willChange = PassthroughSubject<TestObject, Never>()
#Published var items: [TestItem] = [] {
didSet {
willChange.send(self)
}
}
}
try passing .environmentObject on your destination:
NavigationLink(destination: detailView(feed: self._feed, i: i).environmentObject(x))
You have to use willSet instead of didSet.
TestItem should be a value type: struct or enum. SwiftUI's observation system properly works only with value types.

Refreshing a SwiftUI List

Ím trying to refresh this List whenever I click on a NavLink
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: ListFeedItemDetail(idx: i).environmentObject(self.feed)) {
ListFeedItem(item: self.$feed.items[i])
}
}
}
The list is made out of an array inside an environment object.
The problem: It does only refresh when I switch to another tab or close the app
I had used a modal View before and it worked there. (I did it with .onAppear)
Any Ideas?
Example
Problem: When you tap on an item in the list and tap the toggle button the EnvironmentObject is changed but this changed is only reflected when I change the tab and change it back again
import SwiftUI
import Combine
struct TestView: View {
#State var showSheet: Bool = false
#EnvironmentObject var feed: TestObject
func addObjects() {
var strings = ["one","two","three","four","five","six"]
for s in strings {
var testItem = TestItem(text: s)
self.feed.items.append(testItem)
}
}
var body: some View {
TabView {
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: detailView(feed: self._feed, i: i)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}
}
}
}
.tabItem({ Text("Test") })
.tag(0)
Text("Blank")
.tabItem({ Text("Test") })
.tag(0)
}.onAppear {
self.addObjects()
}
}
}
struct detailView: View {
#EnvironmentObject var feed: TestObject
var i: Int
var body: some View {
VStack {
Text(feed.items[i].text)
Text(feed.items[i].read.description)
Button(action: { self.feed.items[self.i].isRead.toggle() }) {
Text("Toggle read")
}
}
}
}
final class TestItem: ObservableObject {
init(text: String) {
self.text = text
self.isRead = false
}
static func == (lhs: TestItem, rhs: TestItem) -> Bool {
lhs.text < rhs.text
}
var text: String
var isRead: Bool
let willChange = PassthroughSubject<TestItem, Never>()
var read: Bool {
set {
self.isRead = newValue
}
get {
self.isRead
}
}
}
class TestObject: ObservableObject {
var willChange = PassthroughSubject<TestObject, Never>()
#Published var items: [TestItem] = [] {
didSet {
willChange.send(self)
}
}
}
I had a similar problem, this is the hack I came up with.
In your "TestView" declare:
#State var needRefresh: Bool = false
Pass this to your "detailView" destination, such as:
NavigationLink(destination: detailView(feed: self._feed, i: i, needRefresh: self.$needRefresh)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}.accentColor(self.needRefresh ? .white : .black)
}
Note ".accentColor(self.needRefresh ? .white : .black)" to force a refresh when "needRefresh"
is changed.
In your "detailView" destination add:
#Binding var needRefresh: Bool
Then in your "detailView" in your Button action, add:
self.needRefresh.toggle()