Published property not updating in SwiftUI - swiftui

I have the following code where I expect updates to the Published property label to be reflected in the UI but it is not.
public class ViewSearch {
#ObservedObject var viewModel: ViewModel
/// Main initializer for instance.
/// - Parameter viewModel: The view model for searching.
public init(viewModel: ViewModel) {
self.viewModel = viewModel
}
func doSomething() {
for i in 1...1000000 {
if i % 250000 == 0 {
viewModel.label = "value: \(i)"
}
}
viewModel.label = "Done!"
}
}
public class ViewModel: ObservableObject {
#Published public var label = "initial value" {
didSet {
print("\(label)")
self.objectWillChange.send()
}
}
#Published public var searchText = ""
var search: ViewSearch? = nil
}
struct ContentView: View {
#ObservedObject var model: ViewModel
var body: some View {
TextField("Search", text: $model.searchText) { isEditing in
if isEditing {
model.label = "initial value"
}
} onCommit: {
if !model.searchText.isEmpty {
model.search = ViewSearch(viewModel: model)
model.search?.doSomething()
}
}
Text(model.label)
}
}
Updates are triggered on the commit of the textfield input. I expect the UI to show "value: 250000", "value: 500000", etc. The didSet observer shows the change but the UI is not updated. Why not?

for loop will block the main queue so create a queue to run for i in 1...1000000 on another thread
//
// ContentView.swift
// StackOverFlow
//
// Created by Mustafa T Mohammed on 12/31/21.
//
import SwiftUI
public class ViewSearch {
#ObservedObject var viewModel: ViewModel
/// Main initializer for instance.
/// - Parameter viewModel: The view model for searching.
public init(viewModel: ViewModel) {
self.viewModel = viewModel
}
func doSomething() {
let q = DispatchQueue.init(label: "doSomething")
// for loop will block the main queue so create a queue to run for i in 1...1000000
// on another thread
q.async { [weak self] in // weak self to prevent retain cycle
guard let self = self else { return }
for i in 1...1000000 {
if i % 250000 == 0 {
DispatchQueue.main.async { //update UI by coming back to main thread
self.viewModel.label = "value: \(i)"
}
}
}
DispatchQueue.main.async { //update UI by coming back to main thread
self.viewModel.label = "Done!"
}
}
}
}
public class ViewModel: ObservableObject {
#Published public var label = "initial value" {
didSet {
print("\(label)")
self.objectWillChange.send()
}
}
#Published public var searchText = ""
var search: ViewSearch? = nil
}
struct ContentView: View {
#ObservedObject var model: ViewModel
var body: some View {
TextField("Search", text: $model.searchText) { isEditing in
if isEditing {
model.label = "initial value"
}
} onCommit: {
if !model.searchText.isEmpty {
model.search = ViewSearch(viewModel: model)
model.search?.doSomething()
}
}
Text(model.label)
}
}

Related

SwiftUI Model by ViewModel edit other ViewModel

let's say you have a list of some objects - let's say cats. There is a common viewmodel that stores the data of the cats received from the server. Each element on the view is a small sector of data for one cat. Within this sector, the data can be changed by the user. How to correctly link the viewmodel of the list of cats and the viewmodel of one cat?
struct Cat : Identifiable {
let id = UUID()
var name: String
var breed: String
}
struct CatListView : View {
#ObservedObject private(set) var viewModel: ViewModel
var body: some View {
List(self.viewModel.items) { (item) in
CatView(viewModel: self.viewModel.createViewModel(item: item))
}
}
}
extension CatListView {
class ViewModel : ObservableObject {
#Published var items: [Cat]
init(items: [Cat]) {
self._items = .init(initialValue: items)
}
public func createViewModel(item: Cat) -> CatView.ViewModel {
.init(data: item)
}
}
}
struct CatView : View {
#ObservedObject private(set) var viewModel: ViewModel
var body: some View {
VStack {
TextField("Name", text: self.$viewModel.data.name)
TextField("Breed", text: self.$viewModel.data.breed)
}
}
}
extension CatView {
class ViewModel : ObservableObject {
#Published var data: Cat
init(data: Cat) {
self._data = .init(initialValue: data)
}
}
}
How to properly transfer changes from CatView.ViewModel to CatListView.ViewModel?
Is Binding possible? But I think this is a variant of the connection between View and View.
Perhaps this option will be correct?
extension CatListView {
class ViewModel : ObservableObject {
#Published private(set) var items: [Cat]
private var cancelItems: [Int: AnyCancellable] = [:]
init(items: [Cat]) {
self._items = .init(initialValue: items)
}
public func createViewModel(item: Cat) -> CatView.ViewModel {
let model: CatView.ViewModel = .init(data: item)
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.cancelItems[index] = model.$data
.dropFirst()
.removeDuplicates()
.sink {
self.items[index] = $0
}
}
return model
}
}
}
You have to manage these items:
Model: single Cat Model
ViewModel: Cat Collection Model that stores all cats and fetch them
View 1 with cats List
View 2 (optional) A View with single cat, can be inside View 1 or separate, but for illustrate this we will separate the View 2.
struct Cat: Identifiable, Hashable {
let id = UUID()
var name: String
var colorDescription: String
}
class CatsModel: ObservableObject {
#Published var catsBag: [Cat]
init() {
// Fetch here the cats from your server
}
// CRUD cicle
func create(_ cat: Cat) { ... }
func update(_ cat: Cat) { ... }
func delete(_ cat: Cat) { ... }
}
struct CatsList: View {
#StateObject var catsModel = CatsModel()
var body: some View {
List {
ForEach(catsModel.catsBag.indices, id: \.self) { catIndice in
CatCell(cat: $catsModel.catsBag[catIndice])
}
}
}
}
struct CatCell: View {
#Binding var cat: Cat
var body: some View {
TextField("The cat name is: ", text: $cat.name)
.padding()
}
}
You will appreciate at this point that if you delete the last cat of that list the app crashes. This could be a SwiftUI error, you can find the solution here: https://www.swiftbysundell.com/articles/bindable-swiftui-list-elements/

SwiftUI can not update class data updates

I came across a situation that you use class data as your data source, and display them in a swiftUI list view, when you update your data source, the swiftUI list view won't be updated, what can we do to make the class data updates interactive with swiftUI?
see code blow:
I define the environment object :
import Foundation
import Combine
class DataSource: ObservableObject {
public static let shared = DataSource()
#Published var datalist: [RowData] = []
func fetch() -> Void {
for n in 1...50 {
let data = RowData(title: "Index:\(n)", count: 0)
datalist.insert(data, at: 0)
}
}
func update() {
for data in datalist {
data.count = data.count+1
print("\(data.title) update count to :\(data.count)")
data.objectWillChange.send()
}
self.objectWillChange.send()
}
}
to display each data in a Row View:
import SwiftUI
struct RowView: View {
#State var data: RowData
var body: some View {
HStack{
Text(data.title)
Spacer()
Text("\(data.count)")
}.padding()
}
}
struct RowView_Previews: PreviewProvider {
static var previews: some View {
RowView(data: RowData(title: "text", count: 1))
}
}
class RowData: ObservableObject {
var title: String = ""
var count: Int = 0
init(title: String, count: Int) {
self.title = title
self.count = count
}
}
in content view, display the data in a list view, I would like to refresh all the view updates when click update button. the button triggers the update methods to update the class data value from data source.
struct ContentView: View {
#EnvironmentObject var data: DataSource
#State var shouldUpdate:Bool = false
#State var localData:[RowData] = []
var body: some View {
VStack {
Button(action: {
// your action here
self.data.update()
self.shouldUpdate.toggle()
self.localData.removeAll()
self.localData = self.data.datalist
}) {
Text("update")
}
List {
ForEach(0..<self.localData.count, id:\.self) { index in
RowView(data: self.localData[index])
}
}
}
}
}
Well... I don't see the reason to have localData, but, anyway, here is modified code that works.
Tested with Xcode 12 / iOS 14
class DataSource: ObservableObject {
public static let shared = DataSource()
#Published var datalist: [RowData] = []
func fetch() -> Void {
for n in 1...50 {
let data = RowData(title: "Index:\(n)", count: 0)
datalist.insert(data, at: 0)
}
}
func update() {
for data in datalist {
data.count = data.count+1
print("\(data.title) update count to :\(data.count)")
}
self.objectWillChange.send()
}
}
struct RowView: View {
#ObservedObject var data: RowData
var body: some View {
HStack{
Text(data.title)
Spacer()
Text("\(data.count)")
}.padding()
}
}
class RowData: ObservableObject {
#Published var title: String = ""
#Published var count: Int = 0
init(title: String, count: Int) {
self.title = title
self.count = count
}
}
struct ContentView: View {
#EnvironmentObject var data: DataSource
#State var localData:[RowData] = []
var body: some View {
VStack {
Button(action: {
// your action here
self.data.update()
self.localData = self.data.datalist
}) {
Text("update")
}
List {
ForEach(0..<self.localData.count, id:\.self) { index in
RowView(data: self.localData[index])
}
}
}
.onAppear {
self.data.fetch()
self.localData = self.data.datalist
}
}
}

How to force List to redraw by another View's toggle Button?

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)

SwiftUI Picker desn't bind with ObservedObject

I'm trying to fill up a Picker with data fetched asynchronously from external API.
This is my model:
struct AppModel: Identifiable {
var id = UUID()
var appId: String
var appBundleId : String
var appName: String
var appSKU: String
}
The class that fetches data and publish is:
class AppViewModel: ObservableObject {
private var appStoreProvider: AppProvider? = AppProvider()
#Published private(set) var listOfApps: [AppModel] = []
#Published private(set) var loading = false
fileprivate func fetchAppList() {
self.loading = true
appStoreProvider?.dataProviderAppList { [weak self] (appList: [AppModel]) in
guard let self = self else {return}
DispatchQueue.main.async() {
self.listOfApps = appList
self.loading = false
}
}
}
init() {
fetchAppList()
}
}
The View is:
struct AppView: View {
#ObservedObject var appViewModel: AppViewModel = AppViewModel()
#State private var selectedApp = 0
var body: some View {
ActivityIndicatorView(isShowing: self.appViewModel.loading) {
VStack{
// The Picker doesn't bind with appViewModel
Picker(selection: self.$selectedApp, label: Text("")) {
ForEach(self.appViewModel.listOfApps){ app in
Text(app.appName).tag(app.appName)
}
}
// The List correctly binds with appViewModel
List {
ForEach(self.appViewModel.listOfApps){ app in
Text(app.appName.capitalized)
}
}
}
}
}
}
While the List view binds with the observed object appViewModel, the Picker doesn't behave in the same way. I can't realize why. Any help ?
I filed bug report, FB7670992. Apple responded yesterday, suggesting that I confirm this behavior in iOS 14, beta 1. It appears to now have been resolved.
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
Picker("", selection: $viewModel.wheelPickerValue) {
ForEach(viewModel.objects) { object in
Text(object.string)
}
}
.pickerStyle(WheelPickerStyle())
.labelsHidden()
}
}
Where
struct Object: Identifiable {
let id = UUID().uuidString
let string: String
}
class ViewModel: ObservableObject {
private var counter = 0
#Published private(set) var objects: [Object] = []
#Published var segmentedPickerValue: String = ""
#Published var wheelPickerValue: String = ""
fileprivate func nextSetOfValues() {
let newCounter = counter + 3
objects = (counter..<newCounter).map { value in Object(string: "\(value)") }
let id = objects.first?.id ?? ""
segmentedPickerValue = id
wheelPickerValue = id
counter = newCounter
}
init() {
let timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
self.nextSetOfValues()
}
timer.fire()
}
}
Results in:
I can't put this into your code because it is incomplete but here is a sample.
Pickers aren't meant to be dynamic. They have to be completely reloaded.
class DynamicPickerViewModel: ObservableObject {
#Published private(set) var listOfApps: [YourModel] = []
#Published private(set) var loading = false
fileprivate func fetchAppList() {
loading = true
DispatchQueue.main.async() {
self.listOfApps.append(YourModel.addSample())
self.loading = false
}
}
init() {
fetchAppList()
}
}
struct DynamicPicker: View {
#ObservedObject var vm = DynamicPickerViewModel()
#State private var selectedApp = ""
var body: some View {
VStack{
//Use your loading var to reload the picker when it is done
if !vm.loading{
//Picker is not meant to be dynamic, it needs to be completly reloaded
Picker(selection: self.$selectedApp, label: Text("")) {
ForEach(self.vm.listOfApps){ app in
Text(app.name!).tag(app.name!)
}
}
}//else - needs a view while the list is being loaded/loading = true
List {
ForEach(self.vm.listOfApps){ app in
Text(app.name!.capitalized)
}
}
Button(action: {
self.vm.fetchAppList()
}, label: {Text("fetch")})
}
}
}
struct DynamicPicker_Previews: PreviewProvider {
static var previews: some View {
DynamicPicker()
}
}

How to manipulate the ion-list item using an ActionSheet in SwiftUI?

I am trying to use an ActionSheet to manipulate items of a List. How can I call a function (in this example deleteItem) that is part of the data model, using an ActionSheet and manipulte the selected item, similar to what .onDelete does?
My view presents items from a model using the following code:
struct ItemManager: View {
#ObservedObject var model: ItemModel
var body: some View {
List {
ForEach(model.items) { item in
ItemCell(item: item)
}
.onDelete { self.model.deleteItem(at: $0) }
}
}
}
struct ItemCell: View {
var item: Item
#State private var isActionSheetVisible = false
private var actionSheet: ActionSheet {
let button1 = ActionSheet.Button.default(Text("Delete")){
self.isActionSheetVisible = false
}
let button2 = ActionSheet.Button.cancel(){
self.isActionSheetVisible = false
}
let buttons = [button1, button2]
return ActionSheet(title: Text("Actions"), buttons: buttons)
}
var body: some View {
VStack(alignment: .leading) {
Button(action: {
self.isActionSheetVisible = true
}) {
Text(item.title).font(.headline)
}.actionSheet(isPresented: self.$isActionSheetVisible) {
self.actionSheet
}
}
}
}
My model has some simple properties and a function that deletes items from the collection:
struct Item: Identifiable, Equatable {
let title: String
var id: String {
title
}
}
class ItemModel: ObservableObject {
#Published var items: [Item] = [Item(title: "temp.1"), Item(title: "temp.2")]
public func deleteItem(at indices: IndexSet) {
indices.forEach { items.remove(at: $0) }
}
}
extension Item {
static let previewItem = Item(title: "temp.3")
}
Update: Added Equatable in the Item declaration to comform.
You could try passing the ItemModel to the ForEach() like so:
ForEach(model.items) { item in
ItemCell(item: item, model: self.model)
}
Then in your ItemCell you can:
struct ItemCell: View {
var item: Item
var model: ItemModel // Add the model variable
#State private var isActionSheetVisible = false
private var actionSheet: ActionSheet {
let button1 = ActionSheet.Button.default(Text("Delete")) {
// Get the index
if let index = self.model.items.firstIndex(of: self.item) {
// Delete the item based on the index
self.model.items.remove(at: index)
// Dismiss the ActionSheet
self.isActionSheetVisible = false
} else {
print("Could not find item!")
print(self.item)
}
}
}
}