How can I update view state in response to external changes? - swift

Imagine I have a view with some mutable state, but that the state might need to be updated to reflect changes in another object (e.g. a ViewModel).
How can I implement that in SwiftUI?
I've tried the following, but can't get the view to reflect updates coming from the ViewModel:
class ViewModel: ObservableObject {
#Published var text: String = "loading"
private var task: AnyCancellable?
func fetch() {
task = Just("done")
.delay(for: 1, scheduler: RunLoop.main)
.assign(to: \.text, on: self)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var viewText = "idle"
private var bind: AnyCancellable?
init() {
viewText = viewModel.text
bind = viewModel
.$text
.print()
.assign(to: \.viewText, on: self)
}
var body: some View {
VStack {
TextField(titleKey: "editable text", text: $viewText)
Text(viewText)
Text(viewModel.text)
}
.onAppear {
self.viewModel.fetch()
}
}
}
The TextField and the first Text element get their content from ContentView.viewText, the second Text goes directly to the source: ViewModel.text.
As expected, the second Text shows "loading" and then "done". The first Text never changes from "idle".

If next screen recording looks like answering your question
it was recorded using next code
import SwiftUI
import Combine
class ViewModel: ObservableObject {
#Published var text: String = "loading"
private var task: AnyCancellable?
func fetch() {
task = Just("done")
.delay(for: 3, scheduler: RunLoop.main)
.assign(to: \.text, on: self)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var viewText = "idle"
var body: some View {
VStack {
Text(viewText)
Text(viewModel.text)
}.onReceive(viewModel.$text.filter({ (s) -> Bool in
s == "done"
})) { (txt) in
self.viewText = txt
}.onAppear {
self.viewModel.fetch()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Here is possible approach (tested & works with Xcode 11.2 / iOS 13.2) - modified only ContentView:
struct ContentView: View {
#ObservedObject var viewModel = ViewModelX()
#State private var viewText = "idle"
init() {
_viewText = State<String>(initialValue: viewModel.text)
}
var body: some View {
VStack {
Text(viewText)
Text(viewModel.text)
}
.onReceive(viewModel.$text) { value in
self.viewText = value
}
.onAppear {
self.viewModel.fetch()
}
}
}

Related

UserDefault value does not update in a list SwiftUI

I have two views, embedded in TabView.
I am using userdefaults in a class called usersettings.
class UserSettings: ObservableObject {
#Published var favList: [String] {
willSet {
print("willset")
}
didSet {
UserDefaults.standard.set(favList, forKey: "isAccountPrivate")
print("didset")
}
}
init() {
self.favList = UserDefaults.standard.object(forKey: "isAccountPrivate") as? [String] ?? ["Sepetiniz Boş"]
}
}
In Button View, which acts like add/remove favorite. It successfully adds and remove from the UserDefaults. But when I add something it does not show on the other view (please see the next code after FavButton)
struct FavButton: View {
#Binding var passedFood: String
#ObservedObject var userSettings = UserSettings()
var body: some View {
Button(action: {
if userSettings.favList.contains(passedFood) {
userSettings.favList.remove(at: userSettings.favList.firstIndex(of: passedFood )!)
} else {
userSettings.favList.append(passedFood)
}
})
}
}
But it does not update my list in this other view unless I close and open my app. If I remove something from the list, it actually removes from the userdefault.
If I add a new word within this view, it works too.
My only problem is when I add something from another view (FavButton) it does not show in this view (FavView).
struct FavView: View {
#ObservedObject var userSettings = UserSettings()
#State private var newWord = ""
var body: some View {
NavigationView {
List {
TextField("Ürün Ekleyin...", text: $newWord, onCommit: addNewWord)
ForEach( self.userSettings.favList, id: \.self) { list in
Text(list)
.font(.headline)
.padding()
}
.onDelete(perform: self.deleteRow)
}
.navigationTitle("Sepetim")
}
}
private func deleteRow(at indexSet: IndexSet) {
self.userSettings.favList.remove(atOffsets: indexSet)
}
private func addNewWord() {
let answer = newWord.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
self.userSettings.favList.append(answer)
guard answer.count > 0 else {
return
}
newWord = ""
}
}
A better approach to follow the SwiftUI idiom is to use the .environmentObject() modifier.
When you declare your app:
struct AppScene: App {
#StateObject private var userSettings = UserSettings() // Use state object to persist the object
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(userSettings) // Inject userSettings into the environment
}
}
}
and then in you ContentView you can reach into your environment and get the object:
struct ContentView: View {
#EnvironmentObject private var userSettings: UserSettings
var body: some View {
Text("Number of items in favList: \(userSettings.favList.count)")
}
}
You need to use same instance of UserSettings in all views where you want to have observed user settings, like
class UserSettings: ObservableObject {
static let global = UserSettings()
//... other code
}
and now
struct FavButton: View {
#ObservedObject var userSettings = UserSettings.global // << here !!
// ... other code
}
and
struct FavView: View {
#ObservedObject var userSettings = UserSettings.global // << here !!
// ... other code
}

Swift Combine Not Receiving Published Values

I am working on a project that is using Combine to get updates from Firebase Firestore. I have a StockListView, a StockListCellView, and a StockDetailView that I want updates to be registered in.
The StockListView holds StockListCellViews which push StockDetailsViews onto the stack. Each view also has a corresponding ViewModel where I am working with Combine.
My trouble is my StockDetailView is not receiving the updates from Combine and I can't see why. Below is a simplified version of the code for each view and viewModel. I think this has something to do with how I am assigning in the StockDetailViewModel but I can't figure it out. Any help would be appreciated.
StockListViewModel - Works Great
class StockListViewModel: ObservableObject {
#Published var stockRepository = StockRepository()
#Published var stockListCellViewModels = [StockListCellViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
stockRepository.$stocks.map { stocks in
stocks.map { stock in
StockListCellViewModel(stockDetailViewModel: StockDetailViewModel(stock: stock))
}
}
.assign(to: \.stockListCellViewModels, on: self)
.store(in: &cancellables)
}
}
StockListView - Works Great
struct StockListView: View {
#ObservedObject var stockRepository = StockRepository()
#ObservedObject var stockListVM = StockListViewModel()
var body: some View {
NavigationView {
List {
ForEach(stockListVM.stockListCellViewModels) { stockListCellVM in
NavigationLink(destination: StockDetailView(stockDetailVM: stockListCellVM.stockDetailViewModel)) {
StockListCell(stockListVM: stockListVM, stockListCellVM: stockListCellVM)
}
}
} // List
.listStyle(PlainListStyle())
.navigationBarTitle("stock")
} // NavigationView
} // View
}
StockListCellViewModel - Works Great
class StockListCellViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var stockDetailViewModel: StockDetailViewModel
#Published var stock: Stock
private var cancellables = Set<AnyCancellable>()
init(stockDetailViewModel: StockDetailViewModel) {
self.stockDetailViewModel = stockDetailViewModel
self.stock = stockDetailViewModel.stock
stockDetailViewModel.$stock.compactMap { stock in
stock.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
stockDetailViewModel.$stock.map { stock in
StockDetailViewModel(stock: stock)
}
.assign(to: \.stockDetailViewModel, on: self)
.store(in: &cancellables)
}
}
StockListCellView - Works Great
struct StockListCell: View {
#ObservedObject var stockListVM: StockListViewModel
#ObservedObject var stockListCellVM: StockListCellViewModel
var body: some View {
Text(stockListCellVM.stock.ticker)
}
}
StockDetailViewModel - Not Updating
class StockDetailViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var stock: Stock
private var cancellables = Set<AnyCancellable>()
init(stock: Stock) {
self.stock = stock
self.chartColor = UIColor()
$stock.compactMap { stock in
stock.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
StockDetailView - Not Updating
struct StockDetailView: View {
#ObservedObject var stockDetailVM: StockDetailViewModel
var body: some View {
Text(stockDetailVM.stock.ticker)
}
}
This took me the better part of two weeks to solve and I wanted to post it here for anyone experiencing the same issue I had. I rebuilt a simple example to make things easier to understand. Here is what the app does.
Connects to a Firestore database and starts a SnapShootListener to collect Note objects.
Once collected it stores the Notes, in an array of Note objects.
This array of notes is built into a List of notes.
Each note progresses to a NoteDetailView.
What I expected to happen was that as a Note was updated on the server, I would see the note update in real-time in the List and in the DetailView. My problem was that I could only see live updates in the List.
The reason for this is because you cannot bind a data element from a ForEach loop. This means that any updates made on the server never make it to the DetailView.
To solve this I introduced EnvironmentObjects and created DetailViews by passing the index of the Note to be shown in the DetailView to the DetailView. This allowed me to access the correct Note in the DetailView. The code below shows how this was done. NOTE: I did not added a NoteDetailViewModel.swift to simplify things.
<YOUR_PROJECT_NAME>App.swift
#main
struct ToDoSwiftUITutorialApp: App {
// 1. An #StateObject was created for the NoteRepository connected to Firestore.
#StateObject private var noteRepository = NoteRepository()
// Firebase
init() {
FirebaseApp.configure()
Auth.auth().signInAnonymously()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(noteRepository) // noteRepository was added to ContentView as an EnvironmentObject.
}
}
}
NoteRepository.swift
class NoteRepository: ObservableObject {
let db = Firestore.firestore()
// Publishing the array of Note object received from Firestore.
#Published var notes = [Note]()
init() {
loadData()
}
func loadData() {
db.collection("notes").addSnapshotListener { (querySnapshot, error) in
if let querySnapshot = querySnapshot {
self.notes = querySnapshot.documents.compactMap { document in
do {
let x = try document.data(as: Note.self)
return x
}
catch {
print(error)
}
return nil
}
}
}
}
}
NoteListView.swift
struct NoteListView: View {
#ObservedObject var noteListViewModel = NoteListViewModel()
var body: some View {
NavigationView {
List {
ForEach(noteListViewModel.noteCellViewModels.indices, id: \.self) { index in
NavigationLink(destination: NoteDetailView(index: index).environmentObject(self.noteListViewModel)) {
NoteCellView(noteCellViewModel: noteListViewModel.noteCellViewModels[index])
} // NavigationLink
} // ForEach
}// List
.navigationBarTitle("Notes")
} // NavigationView
}
}
NoteListViewModel.swift
class NoteListViewModel: ObservableObject {
#Published var noteRepository = NoteRepository()
#Published var noteCellViewModels = [NoteCellViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
noteRepository.$notes.map { notes in
notes.map { note in
NoteCellViewModel(note: note)
}
}
.assign(to: \.noteCellViewModels, on: self)
.store(in: &cancellables)
}
}
NoteCellView.swift
struct NoteCellView: View {
#ObservedObject var noteCellViewModel: NoteCellViewModel
var body: some View {
Text(noteCellViewModel.note.note)
}
}
NoteCellViewModel.swift
class NoteCellViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var note: Note
private var cancellables = Set<AnyCancellable>()
init(note: Note) {
self.note = note
$note.compactMap { note in
note.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
}
NoteDetailView.swift
struct NoteDetailView: View {
#EnvironmentObject var noteRepository: NoteRepository
var index: Int
var body: some View {
Text(noteRepository.notes[index].note)
}
}

Updating State from ObservableObject publisher

I was experimenting with Combine and SwiftUI but stuck in updating state basically I want to update my state in the view every time ObservableObject changes,
here's the example.
class XViewModel: ObservableObject {
#Published var tVal: Bool = false
private var cancellables = Set<AnyCancellable>()
func change() {
Just(true)
.delay(for: 3.0, scheduler: RunLoop.main)
.receive(on: RunLoop.main)
.assign(to: \.tVal, on: self)
.store(in: &self.cancellables)
}
}
I have viewModel and one publisher and delayed publisher which triggers after 3 seconds.
struct ContentView: View {
#ObservedObject var viewModel = XViewModel()
#State var toggleVal: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
self.viewModel.$tVal
.sink { newVal in
print(newVal)
}
.store(in: &cancellables)
self.viewModel
.$tVal.assign(to: \.toggleVal, on: self)
.store(in: &cancellables)
viewModel.change()
}
var body: some View {
VStack {
Toggle(isOn: self.$viewModel.tVal) {
Text("Toggle")
Toggle(isOn: self.$toggleVal) {
Text("Toggle from View")
}
}
}
What I expected is
viewModel.Just triggers
viewModel.tVal publisher triggers
view.toggleVal state triggers
updates UI
But it seems although everything is updated it doesn't update state. Is there any way to update State or it wasn't meant to be updated at all and I need to bind my views directly to the viewModel's tVal value which is publisher.
Thanks.
The #State is not ready in init to operate, use instead .onReceive as below.
struct ContentView: View {
#ObservedObject var viewModel = XViewModel()
#State var toggleVal: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
self.viewModel.$tVal
.sink { newVal in
print(newVal)
}
.store(in: &cancellables)
viewModel.change()
}
var body: some View {
VStack {
Toggle(isOn: self.$viewModel.tVal) {
Text("Toggle")
Toggle(isOn: self.$toggleVal) {
Text("Toggle from View")
}
.onReceive(self.viewModel.$tVal) { newVal in // << here !!
self.toggleVal = newVal
}
}
}

ObservableObject has a different instantiation in my class

I have an ObservableObject that stores the current country the user is wanting information on and I want this to be shared across all views and classes. I properly declared it in the scene delegate so there is no issue with that.
import Foundation
class GlobalData: ObservableObject {
#Published var whichCountry: String = "italy"
}
This is my main view of where I call an environment object to get whichCountry. When the users click the button it will open ListOfCountriesView() and pass that EnvironemtnObject through it to update the new country the users want.
import SwiftUI
struct InDepthView: View {
#State var showList = false
#EnvironmentObject var globalData: GlobalData
#ObservedObject var data = getDepthData(globalData: GlobalData())
var body: some View {
VStack(alignment: .leading) {
Button(action: { self.showList.toggle() }) {
VStack(alignment: .leading) {
HStack {
Text("\(self.data.globalDatam.whichCountry.uppercased())")
}
}
}
.sheet(isPresented: $showList) {
ListOfCountriesView().environmentObject(GlobalData())
}
}
}
}
import SwiftUI
struct ListOfCountriesView: View {
#EnvironmentObject var globalData: GlobalData
var body: some View {
ScrollView {
VStack(spacing: 15) {
Text("List of Countries")
.padding(.top, 25)
Button(action: {
self.globalData.whichCountry = "usa"
self.presentationMode.wrappedValue.dismiss()
}) {
VStack {
Text("\(self.globalData.whichCountry)")
.font(.system(size: 25))
Divider()
}
}
}
}
}
}
struct ListOfCountriesView_Previews: PreviewProvider {
static var previews: some View {
ListOfCountriesView().environmentObject(GlobalData())
}
}
When the user changes the country I want this class which is inside my InDepthView.swift file to be updated with the new string. But for some reason, it is still displaying "italy" when it should have changed to "usa" based on what happened in ListOfCountriesView(). So I know that there is two instantiations of GlobalData but I'm not sure how to fix this issue. Any help would be greatly appreciated as I have been spending the past two days trying to fix this issue!
class getDepthData: ObservableObject {
#Published var data : Specific!
#Published var countries : HistoricalSpecific!
var globalDatam: GlobalData
init(globalData: GlobalData) {
globalDatam = globalData
print(globalDatam.whichCountry + " init()")
updateData()
}
func updateData() {
let url = "https://corona.lmao.ninja/v2/countries/" // specific country
let session = URLSession(configuration: .default
session.dataTask(with: URL(string: url+"\(self.globalDatam.whichCountry)")!) { (data, _, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
let json = try! JSONDecoder().decode(Specific.self, from: data!)
DispatchQueue.main.async {
self.data = json
}
}.resume()
}
}
///////
I added this to the code like you mentioned. but recieving an error
import SwiftUI
struct InDepthView: View {
#State var showList = false
#State var pickerSelectedItem = 1
#EnvironmentObject var globalData: GlobalData
#ObservedObject var data: getDepthData
init() {
self.data = getDepthData(globalData: self.globalData)
}
ERRROR : self' used before all stored properties are initialized
You're creating a second GlobalData instance when you call
#ObservedObject var data = getDepthData(globalData: GlobalData())
Edit: Removed example that was passing the environment object in as an argument. That doesn't work and it crashes.
You will need to refactor a bit to either structure your app a bit differently altogether, or you could remove the environment object, and instead initialise GlobalData() in your first view and then just pass it into each subsequent view as an #ObservedObject, rather than as #EnvironmentObject via scene delegate.
The following is pseudocode but I hope clarifies what I mean:
struct ContentView: View {
#ObservedObject var globalData = GlobalData()
var body: some View {
...
NavigationLink("Linky link", destination: SecondView(globalData: globalData, data: getDepthData(globalData: globalData))
}
}
struct SecondView: View {
#ObservedObject var globalData: GlobalData
#ObservedObject var data: getDepthData
init(globalData: GlobalData, data: getDepthData) {
self.globalData = globalData
self.data = getDepthData
}
...
}

SwiftUI go back programmatically from representable to View

I'm trying to setup a qr reader within a new swift ui app.
I can get load the UIKit qr reader view with this line
NavigationLink(destination: QRCodeScan()){Text("Scan QR")}
This is my ViewControllerRepresentable
struct QRCodeScan: UIViewControllerRepresentable {
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController()
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ vc: ScannerViewController, context: Context) {
}
class Coordinator: NSObject, QRCodeScannerDelegate {
func codeDidFind(_ code: String) {
print(code)
//Go back to the last page, take 'code' with you
}
var parent: QRCodeScan
init(_ parent: QRCodeScan) {
self.parent = parent
}
}
}
At the line 'Go back to the last page...' I need to programmatically return to the page which sent the user to the qr scanner. The page loads with a navigation back button, I pretty much need to replicate this buttons behaviour to call when I need
Any help/pointers appreciated
tia
struct ContentView: View {
#State var isActive = false
#State var code = ""
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: DetailView(isActive: $isActive, code: $code), isActive: $isActive, label: { EmptyView() })
Button(action: {
self.isActive.toggle()
}, label: {
Text("navigate")
})
}
}
}
}
struct DetailView: View {
#Binding var isActive: Bool
#Binding var code: String
var body: some View {
Button(action: {
self.code = "new code"
self.isActive.toggle()
}) {
Text("Back")
}
}
}
This might help you, use isActive parameter of NavigationLink to navigate back and forth
The short answer is you can't do that right now. There is neither a binding nor an environment value to set that can trigger this. My guess is there will be some kind of environment value akin to presentationMode that you can tap into but it isn't currently advertised.
You could try the current presentationMode but my real suggestion is to present your QR scanner as a sheet rather than a push. This may actually make more sense from a navigational standpoint anyway. To do it this way, in your presenter set up a #State var to handle when it's presented.
#State var presentQRScanner = false
var body: some View {
Button("Scan") {
self.presentQRScanner = true
}
.sheet(isPresented: $presentQRScanner) { QRCodeScan() }
}
Then, when you want to programmatically dismiss, your UIViewControllerRepresentable:
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
func scannedCode() {
presentationMode.wrappedValue.dismiss()
}
Alternatively, you can drive this from the presenter too by creating a closure on the QRCodeScan that gets invoked with the code and you have your presenter dismiss.
var onCodeScanned: (Code) -> Void = { _ in }
func scannedCode() {
onCodeScanned(code)
}
and in the presenter:
var body: some View {
Button("Scan") {
self.presentQRScanner = true
}
.sheet(isPresented: $presentQRScanner) {
QRCodeScan(onCodeScanned: {
self.process($0)
self.presentQRScanner = false
})
}
}
EDIT: was not aware of the isActive binding, that should actually work for you if you still want to push your view on the nav stack instead of present it.
You can do it with the following overload of the NavigationLink. It's available since iOS 13 and later.
Here's the code.
Pay attention to passing $isShowingView binding to the both NavigationLink object and to the ChildView that you want to go out on button tap.
struct ContentView: View {
#State var isShowingChildView = false
var body: some View {
NavigationView {
NavigationLink(isActive: $isShowingChildView) {
ChildView(isShowingView: $isShowingChildView)
} label: {
Text("Open new view")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ChildView: View {
#Binding
var isShowingView: Bool
var body: some View {
VStack {
Button("Back to parent view") {
isShowingView = false
}
}
}
}