Strange behaviour of ObservedObject in SwiftUI when using Timer - swiftui

In the following program, Bar's initializer is called for each timer event. Does anyone know the reason of this problem?
This problem happens in both simulators and real devices iOS 13.5. I tested this on Xcode 11.5.
import SwiftUI
import Combine
class Foo: ObservableObject {
#Published var value: Int
init() {
print("init")
self.value = 10
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
self.value += 1
}
}
}
class Bar: ObservableObject {
#Published var value: Int
init() {
print("Bar")
self.value = 100
}
}
struct FirstView: View {
#EnvironmentObject var foo: Foo
#ObservedObject var bar = Bar()
var body: some View {
VStack {
Text("\(foo.value)")
Text("\(bar.value)")
}
}
}
struct ContentView: View {
#EnvironmentObject var foo: Foo
var body: some View {
VStack {
Text("\(foo.value)")
FirstView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Any time Foo, being an ObservedObject, publishes a change, it causes the views that "observe" it to re-render their body
Foo is observed in ContentView, so a change in Foo makes it re-compute its body:
VStack {
Text("\(foo.value)")
FirstView()
}
This calls FirstView(), which in turn calls Bar().

Related

SwiftUI: Must an ObservableObject be passed into a View as an EnvironmentObject?

If I create an ObservableObject with a #Published property and inject it into a SwifUI view with .environmentObject(), the view responds to changes in the ObservableObject as expected.
class CounterStore: ObservableObject {
#Published private(set) var counter = 0
func increment() {
counter += 1
}
}
struct ContentView: View {
#EnvironmentObject var store: CounterStore
var body: some View {
VStack {
Text("Count: \(store.counter)")
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}
Tapping on "Increment" will increase the count.
However, if I don't use the EnvironmentObject and instead pass the store instance into the view, the compiler does not complain, the store method increment() is called when the button is tapped, but the count in the View does not update.
struct ContentViewWithStoreAsParameter: View {
var store: CounterStore
var body: some View {
VStack {
Text("Count: \(store.counter) (DOES NOT UPDATE)")
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}
Here's how I'm calling both Views:
#main
struct testApp: App {
var store = CounterStore()
var body: some Scene {
WindowGroup {
VStack {
ContentView().environmentObject(store) // works
ContentViewWithStoreAsParameter(store: store) // broken
}
}
}
}
Is there a way to pass an ObservableObject into a View as a parameter? (Or what magic is .environmentalObject() doing behind the scenes?)
It should be observed somehow, so next works
struct ContentViewWithStoreAsParameter: View {
#ObservedObject var store: CounterStore
//...
You can pass down your store easily as #StateObject:
#main
struct testApp: App {
#StateObject var store = CounterStore()
var body: some Scene {
WindowGroup {
VStack {
ContentView().environmentObject(store) // works
ContentViewWithStoreAsParameter(store: store) // also works
}
}
}
}
struct ContentViewWithStoreAsParameter: View {
#StateObject var store: CounterStore
var body: some View {
VStack {
Text("Count: \(store.counter)") // now it does update
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}
However, the store should normally only be available for the views that need it, why this solution would make the most sense in this context:
struct ContentView: View {
#StateObject var store = CounterStore()
var body: some View {
VStack {
Text("Count: \(store.counter)")
Button(action: { store.increment() }) {
Text("Increment")
}
}
}
}

How can I use a common variable in a Tab VIew?

I'm currently developing an application using SwiftUI.
This app has 3 structs
①ContentView
②FirstView
③SecondView
These 3 structs do page transition in Tab View.
And this app has a common variable type of Bool using ObservableObject annotation.
I want to change to appear and disappear Text View in the FirstView and the SecondView depends on the condition of the variable, but the FirstView doesn't change a view as I expected...
How can I solve this situation?
Here are the codes:
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
FirstView()
.tabItem {
Text("First")
}.tag(1)
SecondView()
.tabItem {
Text("Second")
}.tag(2)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
FirstView.swift
import SwiftUI
struct FirstView: View {
#ObservedObject var firstCheck: ViewModel = ViewModel()
var body: some View {
VStack{
if firstCheck.check == true{
Text("checked")
}
}
}
}
struct FirstView_Previews: PreviewProvider {
static var previews: some View {
FirstView()
}
}
SecondView.swift
import SwiftUI
struct SecondView: View {
#ObservedObject var secondCheck = ViewModel()
var body: some View {
VStack{
Toggle(
isOn: $secondCheck.check
){
Text("change")
}
if self.secondCheck.check == true{
Text("checked")
}
}
}
}
struct SecondView_Previews: PreviewProvider {
static var previews: some View {
SecondView()
}
}
ViewModel.swift
import Foundation
final class ViewModel: ObservableObject {
#Published var check: Bool = false
}
Xcode: Version 11.7
Keep object in one place, can be parent view
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
// #StateObject var viewModel = ViewModel() // SwiftUI 2.0
var body: some View {
TabView {
// .. other code here
}
.environmentObject(viewModel) // << inject here
}
}
and then use in both views like (for second the same)
struct FirstView: View {
#EnvironmentObject var firstCheck: ViewModel // declare only
// will be injected by type
var body: some View {
VStack{
if firstCheck.check == true{
Text("checked")
}
}
}
}

Swift detect changes to a variable from another view within a view

I have the following Class
class GettingData: ObservableObject {
var doneGettingData = false
{
didSet {
if doneGettingData {
print("The instance property doneGettingData is now true.")
} else {
print("The instance property doneGettingData is now false.")
}
}
}
}
And I'm updating the variable doneGettingData within a mapView structure that then I'm using in my main view (contentView).
This variable its changing from false / true while the map gets loaded and I can detecte it from the print that I have used in the class so I know it's changing.
I want to use it to trigger a spinner within ContentView where I have the following code:
import SwiftUI
import Combine
struct ContentView: View {
var done = GettingData().doneGettingData
var body: some View {
VStack {
MapView().edgesIgnoringSafeArea(.top)
Spacer()
Spinner(isAnimating: done, style: .large, color: .red)
}
}
struct Spinner: UIViewRepresentable {
let isAnimating: Bool
let style: UIActivityIndicatorView.Style
let color: UIColor
func makeUIView(context: UIViewRepresentableContext<Spinner>) -> UIActivityIndicatorView {
let spinner = UIActivityIndicatorView(style: style)
spinner.hidesWhenStopped = true
spinner.color = color
return spinner
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<Spinner>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
What should I do in order to the variable to change as it is changing with the print but inside the view ? I have tried many options but nothing works !
Thank you
Make your property published
class GettingData: ObservableObject {
#Published var doneGettingData = false
}
then make data observed and pass that instance into MapView for use, so modify that property internally
struct ContentView: View {
#ObservedObject var model = GettingData()
var body: some View {
VStack {
MapView(dataGetter: model).edgesIgnoringSafeArea(.top)
Spacer()
Spinner(isAnimating: model.doneGettingData, style: .large, color: .red)
}
}

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.

Invalidate List SwiftUI

Workaround at bottom of Question
I thought SwiftUI was supposed to automatically update views when data they were dependent on changed. However that isn't happening in the code below:
First I make a simple BindableObject
import SwiftUI
import Combine
class Example: BindableObject {
var didChange = PassthroughSubject<Void, Never>()
var test = 1 {
didSet {
didChange.send(())
}
}
}
Then the root view of the app:
struct BindTest : View {
#Binding var test: Example
var body: some View {
PresentationButton(destination: BindChange(test: $test)) {
ForEach(0..<test.test) { index in
Text("Invalidate Me! \(index)")
}
}
}
}
And finally the view in which I change the value of the BindableObject:
struct BindChange : View {
#Binding var test: Example
#Environment(\.isPresented) var isPresented
var body: some View {
Button(action: act) {
Text("Return")
}
}
func act() {
test.test = 2
isPresented?.value = false
}
}
When the return button is tapped there should be 2 instances of the Text View - but there is only 1. What am I doing wrong?
Also worth noting: If I change the #Binding to #EnvironmentObject the program just crashes when you tap the button producing this error:
Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)
Full code below:
import SwiftUI
import Combine
class Example: BindableObject {
var didChange = PassthroughSubject<Example, Never>()
var test = 1 {
didSet {
didChange.send(self)
}
}
static let `default` = {
return Example()
}()
}
//Root View
struct BindTest : View {
#EnvironmentObject var test: Example
var body: some View {
PresentationButton(destination: BindChange()) {
ForEach(0..<test.test) { t in
Text("Invalidate Me! \(t)")
}
}
}
}
//View that changes the value of #Binding / #EnvironmentObject
struct BindChange : View {
#EnvironmentObject var test: Example
#Environment(\.isPresented) var isPresented
var body: some View {
Button(action: act) {
Text("Return")
}
}
func act() {
test.test = 2
isPresented?.value = false
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
//ContentView().environmentObject(EntryStore())
BindTest().environmentObject(Example())
}
}
#endif
EDIT 2: Post's getting a little messy at this point but the crash with EnvironmentObject seems to be related to an issue with PresentationButton
By putting a NavigationButton inside a NavigationView the following code produces the correct result - invalidating the List when test.test changes:
//Root View
struct BindTest : View {
#EnvironmentObject var test: Example
var body: some View {
NavigationView {
NavigationButton(destination: BindChange()) {
ForEach(0..<test.test) { t in
Text("Functional Button #\(t)")
}
}
}
}
}