Update ProgressView value from another ViewController without using #Binding? - ios

Currently I declare my ProgressView in my main view controller which gets it's value from a #State variable. On a button press i change the value of the variable which then updates the value of my progress bar.
I want to make changes to this ProgressView, but using a button on a separate view controller. I've tried to use Binding, but due to the way I am using WatchTabView, this forces me to declare a #Binding variable within my App struct, which isn't possible.
Not sure if I'm overthinking this, but how can i update my progress bar from another view?
Main View
struct ViewController: View {
#State var progressBarValue = 5.0
var body: some View {
ScrollView{
ProgressView("Effort", value: progressBarValue, total: 20)
VStack {
Button{
progressBarValue += 5.0
}label:{
Text("Click")
}
Other View
struct OtherViewController: View{
...
Button{
//I want to increase progressBarValue by clicking here
}label:{
Text("Click")
}
...
}

First please read this post:
What's the difference between a View and a ViewController?
Then read this: https://developer.apple.com/documentation/combine/observableobject
Also, read this: https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject
Finally, come back to this example:
class MyViewController: ObservableObject {
#Published private(set) progress: Float = 0
func updateProgress(by value: Float) {
progress += value
}
}
Parent View:
struct ParentView: View {
#ObservedObject var myController = MyViewController()
var body: some View {
MainChildView()
.environmentObject(myController) //this is one of the ways of passing down an ObservableObject to all underlying views.
//your progress view code can go here
}
}
MainChildView
struct MainChildView: View {
//no need to pass anything into this view because of the EnvironmentObject.
var body: some View {
ChildView()
}
}
ChildView
struct ChildView: View {
#EnvironmentObject var myController: MyViewController //EnvironmentObject will search for MyViewController declared somewhere in one of the parent views
var body: some View {
//your progress view code can go here
Button("Tap", action: {
myController.updateProgress(by: 5)
})
}
}

Related

SwiftUI EnvironmentObject does not redraw the current view but its parent view

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.

SwiftUI retain cycle in view hierarchy

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.

SwiftUI Clear Navigation Stack

In my iOS 14 SwiftUI app, when user is not logged in, I make him go through a few setup screens before presenting the main logged in screen. At the last setup screen, I use a NavigationLink to present the main logged in screen. How do I clear the entire navigation stack such that the main logged in screen becomes the root / first screen in the navigation stack?
Out of curiosity I started thinking about a solution to clear everything down to my root view and remembered that setting the .id() of a view will force a reload.
I'm posting this as a discussion point in first place not as an actual answer since I'm interested in your opinion if this is a legit approach.
Code is kept to the minimum to demonstrate the general idea and I haven't considered any memory leaks yet
import SwiftUI
// MARK: - SessionManager
class SessionManager: ObservableObject {
var isLoggedIn: Bool = false {
didSet {
rootId = UUID()
}
}
#Published
var rootId: UUID = UUID()
}
// MARK: - ContentView
struct ContentView: View {
#ObservedObject
private var sessionManager = SessionManager()
var body: some View {
NavigationView {
NavigationLink("ContentViewTwo", destination: ContentViewTwo().environmentObject(sessionManager))
}.id(sessionManager.rootId)
}
}
// MARK: - ContentViewTwo
struct ContentViewTwo: View {
#EnvironmentObject
var sessionManager: SessionManager
var body: some View {
NavigationLink("ContentViewTwo", destination: ContentViewThree().environmentObject(sessionManager))
}
}
// MARK: - ContentViewThree
struct ContentViewThree: View {
#EnvironmentObject
var sessionManager: SessionManager
var body: some View {
NavigationLink("ContentViewThree", destination: ContentViewFour().environmentObject(sessionManager))
}
}
// MARK: - ContentViewFour
struct ContentViewFour: View {
#EnvironmentObject
var sessionManager: SessionManager
var body: some View {
Button(action: {
sessionManager.isLoggedIn.toggle()
}, label: {
Text("logout")
})
}
}
You can start with .sheet() or .fullScreenCover() on root / first screen,
and then stack with NaviagtionLink,
and at the end self.presentationMode.wrappedValue.dismiss()
Now that I am aware of .sheet() and .fullscreenCover(), I would prefer that answer, but being new to swiftUI this was my initial approach.
You could use a #State variable to control the behaviour. Create two navigation views, that are enclosed in a if condition based on your bool.
#State var showOnBoarding = true
if $showOnBoarding {
OnboardingNavigationView()
} else {
CoreNavigationView()
}
One with on-boarding links and the other containing your core navigation links. One for your onboarding and then one for logged in.
Each of your navigation links would also be initialized with the isActive Binding parameter.
struct OnboardingNavigationView: View {
var body: some View {
NavigationView {
NavigationLink("Step 1",destination: NextStepView(), isActive: $showOnBoarding)
//etc..
}
}
}
struct CoreNavigationView: View {
var body: some View {
NavigationView {
NavigationLink("Login Screen",destination: AccountView(), isActive: $showOnBoarding)
//etc..
}
}
}
Once the user logs in, you'd toggle that var. Each of your NavigationLinks would use the isActive property bound to showOnBoarding. So once logged in they won't be able go back to the inactive onboarding screens and will be in your 'new' navigation stack where your login screen is the root screen.
See section 'Presenting a Destination View with Programmatic Activation'
SwiftUI NavigationLink

Reload view when #Published is changed

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)
}
}

SwiftUI - EnvironmentObject Published var to NavigationLink's Selection?

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
}
}
}
}

Resources