Working on my first SwiftUI project, and as I started moving some of my more complex views into their own view structs I started getting problems with the views not being redrawn.
As an example, I have the following superview:
struct ContainerView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.padding(.vertical, 10)
.frame(idealHeight: 10)
.padding(.horizontal, 8)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
myDataObject = DataManager.shared.randomData
}
}
Now when this first gets drawn I get the Text view on screen as the myDataObject var is nil, but the .onAppear from that gets called, and myDataStruct gets set with an actual struct. I've added breakpoints in the body variable, and I see that when this happens it gets called again and it goes into the first if clause and fetches the "TheSmallerView" view, but nothing gets redrawn on screen. It still shows the Text view from before.
What am I missing here?
EDIT: Here's the relevant parts of TheSmallerView:
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
EDIT2: Fixed the code to better reflect my actual code.
Try declaring #Binding var myDataStruct: MyDataStruct inside the TheSmallerView view and pass it like this: TheSmallerView(myDataStruct: $myDataStruct) from ContainerView
You are using #ObservedObject in the subview, but that property wrapper is only for classes (and your data is a struct).
You can use #State instead (b/c the data is a struct).
Edit:
The data isn't a struct.
Because it is a class, you should use #StateObject instead of #State.
In lack of complete code I created this simple example based on OPs code, which works fine the way it is expected to. So the problem seems to be somewhere else.
class MyDataObject: ObservableObject {
#Published var number: Int
init() {
number = Int.random(in: 0...1000)
}
}
struct ContentView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
myDataObject = MyDataObject()
}
}
}
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
var body: some View {
Text("The number is: \(myDataObject.number)")
}
}
Related
I'm trying to understand how #EnvironmentObject affects redrawing when a property in ObservableObject changes.
As per Apple's doc on EnvironmentObject,
An environment object invalidates the current view whenever the observable object changes.
If the above statement is true, wouldn't the below code invalidate and recreate ChildView when you press the button in ContentView. And that should print the following output.
initializing parent
initializing child
// after pressing the button
initializing child
Contrary to my above assumption, it actually prints
initializing parent
initializing child
// after pressing the button
initializing parent
Can anyone explain why this is the case? Why is the ParentView being recreated even though ParentView is not depending on Library?
class Library: ObservableObject {
#Published var item: Int = 0
}
struct ContentView: View {
#StateObject var library: Library = Library()
var body: some View {
VStack {
ParentView()
.environmentObject(library)
Button {
library.item += 1
} label: {
Text("increment")
}
}
}
}
struct ParentView: View {
init() {
print("initializing parent")
}
var body: some View {
VStack {
Text("Parent view")
ChildView()
}
}
}
struct ChildView: View {
#EnvironmentObject var library: Library
init() {
print("initializing child")
}
var body: some View {
Text("Child view")
}
}
SwiftUI View´s can be a little tricky.
An environment object invalidates the current view whenever the observable object changes.
does not mean the object itself is recreated. It just means the body of the view gets called and the view rebuilds itself.
Remember the struct is not the View itself, it´s just a "description".
I´ve added some print statements to make this more clear:
struct ContentView: View {
#StateObject var library: Library = Library()
init(){
print("initializing content")
}
var body: some View {
VStack {
let _ = print("content body")
ParentView()
.environmentObject(library)
Button {
library.item += 1
} label: {
Text("increment")
}
}
}
}
struct ParentView: View {
init() {
print("initializing parent")
}
var body: some View {
VStack {
let _ = print("parent body")
Text("Parent view")
ChildView()
}
}
}
struct ChildView: View {
#EnvironmentObject var library: Library
init() {
print("initializing child")
}
var body: some View {
let _ = print("child body")
Text("child")
}
}
this initially prints:
initializing content
content body
initializing parent
parent body
initializing child
child body
and after pressing the button:
content body
initializing parent
child body
As you see the body of those View´s depending on Library get their respective body reevaluated.
The ParentView initializer runs because in your ContentView you call ParentView() in the body so a new struct "describing" your View is created. The ParentView´s view itself stays the same so its body var is not called.
This WWDC 2021 video about SwiftUI Views will help you better understand this.
I'm having the following view hierarchy which has a retain cycle, that's the simplest I could make to reproduce the issue. All viewmodels and properties has to stay as they are needed in the original solution:
import SwiftUI
struct MainView: View {
#StateObject var viewModel = MainViewModel()
var body: some View {
NavigationView { [weak viewModel] in
VStack {
Button("StartCooking") {
viewModel?.show()
}
if viewModel?.isShowingContainerView == true {
ContainerView()
}
Button("StopCooking") {
viewModel?.hide()
}
}
}
.navigationViewStyle(.stack)
}
}
final class MainViewModel: ObservableObject {
#Published var isShowingContainerView = false
func show() {
isShowingContainerView = true
}
func hide() {
isShowingContainerView = false
}
}
struct ContainerView: View {
#Namespace var namespace
var body: some View {
VStack {
SubView(
namespace: namespace
)
}
}
}
struct SubView: View {
#StateObject var viewModel = SubViewModel()
var namespace: Namespace.ID
var body: some View {
Text("5 min")
.matchedGeometryEffect(id: UUID().uuidString, in: namespace)
.onTapGesture {
foo()
}
}
private func foo() {}
}
final class SubViewModel: ObservableObject {}
If I run the app, tap on StartCooking, than on StopCooking and check the memory graph, I still see an instance of SubViewModel, which means that there is a leak in this code.
If I remove:
NavigationView OR
The VStack from ContainerView OR
matchedGeometryEffect OR
tapGesture
The retain cycle is resolved. Unfortunately I need all these. Can you see what the issue might be and how could it be solved?
Looks like a SwiftUI bug. A possible workaround (if sub-view is one or limited set) is to use view model factory to provided instances.
Here is an example for one view:
struct SubView: View {
#StateObject var viewModel = SubViewModel.shared // single instance !!
// .. other code
}
final class SubViewModel: ObservableObject {
static var shared = SubViewModel() // << this !!
}
I could kind of workaround it by making every property optional in the SubViewModel and running a function when the SubViews disappear, which makes them nil. The SubViewModel still stays in the memory, but will not take up that much space.
Interestingly I even tried to make the viewmodel optional, and make it nil when the view disappears, but it still stayed in the memory.
I'm trying to learn SwiftUI and I'm going to develop a simple app with tab views and sharing core motion data between those views.
The main idea is to create a motion manager object (like here) and use the sensor values in all views.
ContentView.swift:
import SwiftUI
struct ContentView: View {
#State private var selection = 1
#State private var viewNames : [String] = ["View1", "View2"]
var body: some View {
TabView(selection: $selection){
View1(viewName: $viewNames[0]).tag(0)
View2(viewName: $viewNames[1]).tag(1)
}
}
}
View1.swift:
import SwiftUI
struct View1 : View {
#Binding var viewName : String
var body: some View {
Text("First View")
.font(.title)
.foregroundColor(Color.gray)
.tabItem {
VStack {
Image(systemName: "star")
Text(viewName)
}
}
}
}
View2.swift:
struct View2 : View {
#Binding var viewName : String
var body: some View {
VStack {
Text("Second View")
.font(.title)
.foregroundColor(Color.green)
.padding(.top)
View21(motionManager: MotionManager())
}.tabItem {
VStack {
Image(systemName:"heart")
Text(viewName)
}
}
}
}
View21.swift
struct View21 : View {
#ObservedObject var motionManager : MotionManager
#State private var showDetails = false
var body: some View{
Text(String(format: "%.2f", motionManager.x))
}
With these code I can use the sensor data in View21, but I can't access the data in the views in the hierarchy above.
Furthermore I created #ObservedObject in the ContentView (like here) and passed it through all views. My app work in the simulator, but it doesn't on the real device.
I can see sensor data changing, but I can't switch the tab views.
I've tried to use #EnvironementObject instead of #ObservedObject, but the behavior is the same.
I'll be very thankful for any help and tipps to my issue.
Best wishes
Okay, so Paulw11 is right that the you probably want to inject your ObservableObject into your environment, then in each view that wants to access that instance, you just add a property with the #EnvironmentObject property wrapper. Below I've thrown together the simplest example I could think of, so that you can get the idea of how it works.
import SwiftUI
import Combine
class ManagerPlaceholder: ObservableObject {
#Published var propertyOne: Double = 1.0
#Published var propertyTwo: Double = 2.0
func action() {
propertyOne = Double.random(in: 0.0..<100.00)
propertyTwo = Double.random(in: 0.0..<100.00)
}
}
struct ContentView: View {
#EnvironmentObject var manager: ManagerPlaceholder
var body: some View {
TabView {
Subview()
.tabItem { Label("First", systemImage: "hexagon.fill") }
.tag(1)
Subview()
.tabItem { Label("Second", systemImage: "circle.fill") }
.tag(2)
}
}
}
struct Subview: View {
#EnvironmentObject var manager: ManagerPlaceholder
var body: some View {
VStack {
Text("Prop One: \(manager.propertyOne)").padding()
Text("Prop Two: \(manager.propertyTwo)").padding()
Button("Change", action: manager.action).padding()
}
}
}
So above is
A simple ObservableObject - All it does is set two Doubles with a random value (notice the properties you want to observe are marked as #Published)
A tab view
A simple sub-view
Notice that both of the views have a #EnvironmentObject var manager: ManagerPlaceholder. You don't set that property directly. That line says that you want to reference a ManagerPlaceholder instance that's in the Environment. The environment is a sort of "pool" of storage that SwiftUI manages for you. You can add an instance of an object to it, then reference it in sub-views that need it.
So to make sure that's in the environment you add it when instantiating a view (could be the tab view, or any super-view). So for example in your _NAME_App.swift (for an iOS 14 target) or SceneDelegate.swift (for an iOS 13 target), you'd instantiate your view like this:
ContentView().environmentObject(ManagerPlaceholder())
If you run the code above you'll see when you hit the Change button it randomly sets the two properties, and both subviews will see the exact same values when you switch back and forth, because they're both referencing the same instance.
Feel free to comment if anything is unclear.
I want to be able to scan barcodes, then show the barcode at the bottom of the screen in a sheet, or a separate view.
When I update the 'barcode' variable which is #Published and accessed in other methods with #ObserveableObject, it doesn't update the view with the data from the barcode.
Content View
class ScannedCode: ObservableObject {
#Published var barcode = ""
}
struct ContentView: View {
#ObservedObject var barcode = ScannedCode()
var body: some View {
ZStack {
ScannerView()
FoundItemSheet()
}
}
}
Scanner View - scannedCode is linked to the #Published variable, so when this changes, I want it to reload FoundItemSheet() as a barcode has been found by the scanner
class Coordinator: BarcodeScannerCodeDelegate, BarcodeScannerErrorDelegate {
#ObservedObject var scannedCode = ScannedCode()
private var scannerView: ScannerView
init(_ scannerView: ScannerView) {
self.scannerView = scannerView
}
func scanner(_ controller: BarcodeScannerViewController, didCaptureCode code: String, type: String) {
self.scannedCode.barcode = code
controller.resetWithError(message: "Error message")
}
func scanner(_ controller: BarcodeScannerViewController, didReceiveError error: Error) {
print(error)
}
}
FoundItemSheet() Loads BottomSheetView which is shown at the bottom of the screen in a box over the camera. I want this to update with the barcode data when it's found.
struct FoundItemSheet: View {
#State private var bottomSheetShown = false
var body: some View {
GeometryReader { geometry in
BottomSheetView(
isOpen: self.$bottomSheetShown,
maxHeight: geometry.size.height * 0.7
) {
Color.blue
}
}.edgesIgnoringSafeArea(.all)
}
}
BottomSheetView - I have declared the #Published barcode variable in here, so when it changes, I want contentsInSlide to reload with the new barcode.
struct BottomSheetView<Content: View>: View {
#Binding var isOpen: Bool
#State var showingDetail = false
#ObservedObject var scannedCode = ScannedCode()
....
private var contentInSlide: some View {
VStack {
Text("Value is: \(scannedCode.barcode)") //Doesn't show any value for the barcode
.foregroundColor(ColorManager.beautytruthGreen)
.font(.system(size: 22, weight: .medium))
Button(action: {
self.showingDetail.toggle()
}
}
The problem is that all of your views create their own ScannedCode instances. You need to create it only on the type that creates all other views and inject the same instance into each one of them.
From your code snippets it isn't really clear which view is the parent of which, so it's hard to give you a definitive answer with a code example, but in general, you should never create an #ObservableObject in a view itself, since that object will be recreated as soon as the view is reloaded. Instead, you should be creating your #ObservableObject on the view model or the parent view and injecting it into the child view that needs reloading.
Using below code, whenever scannedCode on the ParentView is updated, it reloads its ChildView with the updated ScannedCode.
struct ParentView: View {
#Published var scannedCode = ScannedCode()
var body: some View {
ChildView(scannedCode: scannedCode)
}
}
struct ChildView: View {
#ObservedObject var scannedCode: ScannedCode
var body: some View {
Text(scannedCode.barCode)
}
}
I have a class I use as EnvironmentObject with a screen variable I want to use to control what screen the user is in. I use network calls to change this value, expiration seconds to move to another view, so I need my NavigationView to move to the adequate screen when this value changes.
For the class I have something like this:
class MyClass: NSObject, ObservableObject {
#Published var screen: String? = "main"
}
And for the main view I have something like this:
struct ContentView: View {
#EnvironmentObject var myClass: MyClass
var body: some View {
ZStack {
NavigationView() {
NavigationLink(destination: MainView(), tag: "main", selection: self.$myClass.screen)
{
EmptyView()
}
}
}
}
}
I don't seem to be able to work this way.
As a workaround I have done:
struct ContentView: View {
#EnvironmentObject var myClass: MyClass
var body: some View {
VStack {
if (self.myClass.screen == "main") {
MainView()
} else if (self.myClass.screen == "detail") {
DetailView()
}
}
}
But as you see, is not pretty. And I don't get any animations when changing screens.
Does anyone have an idea how to do this or how should I approach this situation?
Your second approach is correct. You just need animations. Unfortunately they only work when changing #State variables.
For this you need to create a new #State variable and assign it in .onReceive:
struct ContentView: View {
#EnvironmentObject var myClass: MyClass
#State var screen: String?
var body: some View {
VStack {
if screen == "main" {
MainView()
} else if screen == "detail" {
DetailView()
}
}
.onReceive(myClass.$screen) { screen in
withAnimation {
self.screen = screen
}
}
}
}