I come from the UIKit universe and am struggling to understand SwiftUI's object observers.
Here is what I have:
MyEnum
enum MyEnum: String, Identifiable, CaseIterable {
static var all: [MyEnum] { MyEnum.allCases.sorted(by: { $0.value > $1.value }) }
case value1 = "value1"
case value2 = "value2"
var value: Double { UserDefaults.standard.double(forKey: rawValue) }
}
I also have a service that calls an API and updates all the MyEnum values (by setting the new value in the UserDefaults).
And the UI:
struct ContentView: View {
var body: some View {
TestHeader()
TestList()
}
}
struct TestHeader: View {
var body: some View {
Button {
APIService.reloadAll()
}, label: {
Text("Update")
}
}
}
struct TestList: View {
var body: some View {
List {
ForEach(MyEnum.all) {
MyEnumRow(myEnum: $0)
.listRowInsets(EdgeInsets())
}
}
.listStyle(PlainListStyle())
}
}
struct MyEnumRow: View {
var myEnum: MyEnum
var body: some View {
HStack(spacing: 10) {
Text(myEnum.rawValue)
Text(myEnum.value)
}
}
}
Of course when I tap the update button the myEnum list isn't updated, but I don't know how to make it responsive to content change. Could you please help me?
You can use #AppStorage to watch a particular value in UserDefaults. This is slightly more complex in your case because you don't know what key to be watching until the view is created. I opted to have the AppStorage passed in from the parent view.
struct ContentView: View {
var body: some View {
TestHeader()
TestList()
}
}
struct TestHeader: View {
var body: some View {
Button(action: {
UserDefaults.standard.set(Double(Date().timeIntervalSince1970), forKey: MyEnum.allCases.first!.rawValue)
}) {
Text("Update")
}
}
}
struct TestList: View {
var body: some View {
List {
ForEach(MyEnum.all) {
MyEnumRow(myEnum: $0, valueStorage: AppStorage(wrappedValue: 0, $0.rawValue))
.listRowInsets(EdgeInsets())
}
}
.listStyle(PlainListStyle())
}
}
struct MyEnumRow: View {
var myEnum: MyEnum
#AppStorage var valueStorage : Double
var body: some View {
HStack(spacing: 10) {
Text(myEnum.rawValue)
Text("\(valueStorage)")
}
}
}
enum MyEnum: String, Identifiable, CaseIterable {
static var all: [MyEnum] { MyEnum.allCases.sorted(by: { $0.value > $1.value }) }
case value1 = "value1"
case value2 = "value2"
var value: Double { UserDefaults.standard.double(forKey: rawValue) }
var id: String { self.rawValue }
}
Related
So let's say I have a list component in SwiftUI:
struct MyListView: View {
var body: some View {
List(...) { rec in
Row(rec)
}
}
}
Now let's say I want to make this reusable, and I want the "caller" of this view to determine what happens when I tap on each row view. What would be the correct way to insert that behavior?
Here is some other Buttons in ListView example that you can run and play with it yourself
import SwiftUI
struct TestTableView: View {
#State private var item: MyItem?
var body: some View {
NavigationView {
List {
// Cell as Button that display Sheet
ForEach(1...3, id:\.self) { i in
Button(action: { item = MyItem(number: i) }) {
TestTableViewCell(number: i)
}
}
// Cell as NavigationLink
ForEach(4...6, id:\.self) { i in
NavigationLink(destination: TestTableViewCell(number: i)) {
TestTableViewCell(number: i)
}
}
// If you want a button inside cell which doesn't trigger the whole cell when being touched
HStack {
TestTableViewCell(number: 7)
Spacer()
Button(action: { item = MyItem(number: 7) }) {
Text("Button").foregroundColor(.accentColor)
}.buttonStyle(PlainButtonStyle())
}
}
}.sheet(item: $item) { myItem in
TestTableViewCell(number: myItem.number)
}
}
struct MyItem: Identifiable {
var number: Int
var id: Int { number }
}
}
struct TestTableViewCell: View {
var number: Int
var body: some View {
Text("View Number \(number)")
}
}
Make it like Button and takes an action param that is a closure.
From my understanding you're looking for a reusable generic List view with tap on delete functionality. If I'm guessing right my approach then would be like this:
struct MyArray: Identifiable {
let id = UUID()
var title = ""
}
struct ContentView: View {
#State private var myArray = [
MyArray(title: "One"),
MyArray(title: "Two"),
MyArray(title: "Three"),
MyArray(title: "Four"),
MyArray(title: "Five"),
]
var body: some View {
MyListView(array: myArray) { item in
Text(item.title) // row view
} onDelete: { item in
myArray.removeAll(where: {$0.id == item.id}) // delete func
}
}
}
struct MyListView<Items, Label>: View
where Items: RandomAccessCollection, Items.Element: Identifiable, Label: View {
var array: Items
var row: (Items.Element) -> Label
var onDelete: (Items.Element) -> ()
var body : some View {
List(array) { item in
Button {
onDelete(item)
} label: {
row(item)
}
}
}
}
I often face this situation but so far I could not find a good solution. Thing is when my SwiftUI View gets bigger I refactor the code by making another struct and call the struct in the respective view. Say I got a struct A and I refactor some code in struct B, but how can I update the view or call a function in struct A depending on button click on struct B ? The below code might help to understand the situation:
import SwiftUI
struct ContentView: View {
#State var myText: String = "Hello World"
#State var isActive: Bool = false
var body: some View {
VStack {
Text(self.myText)
AnotherStruct(isActive: $isActive)
}
.onAppear {
if self.isActive == true {
self.getApi()
}
}
}
func getApi() {
print("getApi called")
self.myText = "Hello Universe"
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct AnotherStruct: View {
#Binding var isActive: Bool
var body: some View {
VStack {
Button( action: {
self.isActive.toggle()
}) {
Text("Button Tapped")
}
}
}
}
Here is a demo of possible approach to solve such cases - with separated responsibility and delegated activity.
struct ContentView: View {
#State var myText: String = "Hello World"
var body: some View {
VStack {
Text(self.myText)
AnotherStruct(onActivate: getApi)
}
}
func getApi() {
print("getApi called")
self.myText = "Hello Universe"
}
}
struct AnotherStruct: View {
let onActivate: () -> ()
// #AppStorage("isActive") var isActive // << possible store in defaults
var body: some View {
VStack {
Button( action: {
// self.isActive = true
self.onActivate()
}) {
Text("Button Tapped")
}
}
// .onAppear {
// if isActive {
// self.onActivate()
// }
// }
}
}
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)
}
}
}
I fetched JSON data from Google Sheet and populate into a List using ForEach. I used struct HeaderView located in another View and place a Button to serve as a toggle. However, the List will not redraw when I press the toggle button even I use #State ascd variable.
Below is some of my code, is there anything I miss?
struct HeaderView: View {
// #State var asc: Bool = true
var holding: String = "ζε"
var earning: String = "θ³Ίθ"
// #State var tog_value: Bool = ContentView().ascd
var body: some View {
HStack {
Button(action: {
ContentView().ascd.toggle()
}
) {
Text("Button")
}
Text(holding)
Text(earning)
}
}
}
struct ContentView: View {
#ObservedObject var viewModel = ContentViewModel()
#ObservedObject var viewModelTotal = ContentViewModelTotal()
#State var ascd: Bool = false
var totalss = ContentViewModelTotal.fetchDatasTotal
var body: some View {
List {
Section(header: HeaderView()) {
ForEach(viewModel.rows, id: \.stockname) { rows in
// Text(user.stock_name)
ListRow(name: rows.stockname, code: rows.stockcode, cur_price: rows.currentprice, mkt_value: rows.marketvalue, amnt: rows.amount, avg_cost: rows.averagecost, pft: rows.profit, pft_pcnt: rows.profitpercent)
}
}
.onAppear {
self.viewModel.fetchDatas()
self.ascd.toggle()
if self.ascd {
self.viewModel.rows.sort { $0.stockname < $1.stockname }
} else {
self.viewModel.rows.sort { $0.stockname > $1.stockname }
}
}
}
}
}
For changing another View's variable you can use a #Binding variable:
struct HeaderView: View {
...
#Binding var ascd: Bool
var body: some View {
HStack {
Button(action: {
self.ascd.toggle()
}) {
Text("Button")
}
Text(holding)
Text(earning)
}
}
}
I'd recommend moving sorting logic to your ViewModel.
class ContentViewModel: ObservableObject {
#Published var ascd: Bool = false {
didSet {
if ascd {
rows.sort { $0.hashValue < $1.hashValue }
} else {
rows.sort { $0.hashValue > $1.hashValue }
}
}
}
...
}
If it's in the .onAppear in the ContentView it will be executed only when your View is shown on the screen.
And you will have to initialise your HeaderView with your ViewModel's ascd variable:
HeaderView(ascd: $viewModel.ascd)
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.