SwiftUI: How to pass selected item back to parent using ViewModels - ios

I'm trying to shape out the best way to pass the info back from child to parent when both use View Models and need the item to perform some work. There are a lot of similar topics on SO, but none seems to tackle the issue that I've got. I understand there might be multiple solutions to solve this project, but none of them convince me to be correct.
Parent
screen when you have a selected item and can save it later
struct ParentView: View {
#StateObject viewModel = ParentViewModel()
}
class ParentViewModel: : ObservableObject {
#Published var selectedItem: Item?
func saveSelectedItem() {
// some logic to perform the request and save selectedItem, of course when it is not nil
}
}
ChildView
Fetches all the possible items and allows selecting the item which should be later passed to parent.
struct ChildView: View {
#StateObject viewModel = ChildViewModel()
**(Problem)**
// presents the list of all the items and highlights the current one (if not nil)
// on click sends the info back to Parent
}
class ChildViewModel: : ObservableObject {
#Published var items: [Item] = []
**(Problem)**
func loadItems() {
// fetches the items and sorts them
}
}
What I would like to do is to pass selectedItem to the ChildView and be notified when it is selected there on the list, but the child view model needs this to properly sort the items.
PROBLEM
What is the best approach to pass the selectedItem to the child and use it in ViewModel?
If I pass it as a Binding then the ChildView owns it, and I require this in my ChildViewModel to sort the items based on selectedItem.
If I perform an action to highlight the selected item based on state of the ChildViewModel (items) and selectedItem isn't a bit of a mess?
struct ChildView: View {
#StateObject viewModel = ChildViewModel()
#Binding var selectedItem: Item? // How can I use this in ChildViewModel, I need it to sort fetched items properly
}
I guess I should not pass binding to ViewModel as it is part of the SwiftUI framework?
class ChildViewModel: : ObservableObject {
#Published var items: [Item] = []
#Binding var selectedItem: Item? // I guess this is not correct, I don't even know how to pass it here
}
I know I can use completion block and initialize ChildViewModel with wrappedValue of the selectedItem but I would love to use SwiftUI/Combine approach.

In SwiftUI, you can use a combination of #Binding and #State to pass data back to a parent view.
Here's an example:
struct ParentView: View {
#State private var selectedItem: Item?
var body: some View {
VStack {
// Other views...
ChildView(selectedItem: $selectedItem)
}
}
}
struct ChildView: View {
#Binding var selectedItem: Item?
var body: some View {
List {
// List of items...
Button(action: {
self.selectedItem = item
}) {
Text(item.name)
}
}
}
}
In this example, the ChildView has a selectedItem binding that is passed in from the parent view. When a user taps on one of the items in the list, the selectedItem is updated with the selected item. This change is automatically propagated back to the parent view because of the #Binding property wrapper.

Related

Update ProgressView value from another ViewController without using #Binding?

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

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

How to create a view that works with #Binding. It should refresh itself while working with #Binding data

This question is identical to SwiftUI #Binding update doesn't refresh view, but the accepted answer is not applicable for my case.
The accepted answer says
A View using a #Binding will update when the underlying #State change, but the #State must be defined within the view hierarchy. (Else you could bind to a publisher)
In my case, the view hierarchy doesn't have the view which is having the #State. The view having the binding is presented modally to the user.
To summarize the issue again
I want to create a view, similar to Toggle which initializes from a Binding. This view will show the contents from the wrapped value and as it performs the updates, the original storage of the value will get updated automatically.
As I have learnt, updating the #Binding in a view, doesn't invalidate it. Then how to implement such a view.
Also I can't depend on the parent view to eventually update this view, because the view is shown on a modally presented screen.
I don't want to use workarounds like using a #State to explicitly trigger a refresh. So what is the correct way to implement such a view.
Code example
The view TextModifier takes a Binding. The view does some modifications to the view. For now it just appends "_Updated" to the value passed.
I initialize the view as TextModifier(text: <some_binding_var>)
struct TextModifier: View {
#Binding var text: String
var body: some View {
Text(text)
.onTapGesture {
text += "_Updated"
}
}
}
This view shows the text and on tapping it updates it in the original source, but as expected the view doesn't update itself on tapping.
So, how to implement this view so that it also updates itself when it updates the binding value.
The accepted answer to the linked question also says
Else you could bind to a publisher
I don't know how to do this. Does anybody know how to implement this and also provide a code example. Thanks.
Updated with full code and gif
struct ContentView: View {
#ObservedObject var viewModel = TestViewModel()
var body: some View {
List {
ForEach(viewModel.itemsList, id: \.self) { item in
ItemView(text: $viewModel.itemsList[getItemIndex(item)])
}
}
}
private func getItemIndex(_ item: String) -> Int {
viewModel.itemsList.firstIndex { $0 == item }!
}
}
class TestViewModel: ObservableObject {
#Published var itemsList = ["Item 1", "Item 2", "Item 3"]
}
struct ItemView: View {
#Binding var text: String
#State private var showEditorView = false
var body: some View {
Text(text)
.onTapGesture {
showEditorView = true
}
.sheet(isPresented: $showEditorView) {
TextModifier(text: $text, showView: $showEditorView)
}
}
}
struct TextModifier: View {
#Binding var text: String
#Binding var showView: Bool
var body: some View {
VStack(spacing: 20) {
Text("Tap on the text to update it")
.foregroundColor(.blue)
Text(text)
.onTapGesture {
text += "_Updated"
}
Button {
showView = false
} label: {
Text("Dismiss")
.foregroundColor(.blue)
}
}
}
}

Custom picker with access to option value instead of just selected index

I'm trying to implement a custom Picker that makes a custom value available to the parent (as opposed to just the selected index). In the example below, I've got a #State variable bound to selection on the picker as well as a computed variable selectedOption which updates correctly.
import SwiftUI
struct CustomPicker: View {
var options = ["option1", "option2", "option3"]
#State var selectedIndex = 0
var selectedOption {
options[selectedIndex]
}
var body: some View {
Picker(selection: $selectedIndex, label: Text("")) {
ForEach(0..<self.options.count) { index in
Text("\(self.options[index])").tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
}
}
However, I want to be able to interact with the CustomerPicker in another view like this:
#State var selectedOption: string;
...
CustomPicker(selectedOption: $selectedOption)
So that the parent view is dealing with the option directly, as opposed to the index. Does anyone have any tips as to how I would go about this using SwiftUI?
Your custom picker needs #Binding to the selected index. I think it would also be a good idea to offer a "no selection" option to the picker itself:
struct CustomPicker: View {
var options = ["option1", "option2", "option3"]
#Binding var selectedIndex: Int?
var body: some View {
Picker(selection: $selectedIndex,
label: Text("")) {
Text("no selection")
.tag(Optional<Int>.none)
ForEach(0..<self.options.count) { index in
Text("\(self.options[index])")
.tag(index)
}
} .pickerStyle(SegmentedPickerStyle())
}
}

In SwiftUI, can I pass #EnvironmentObject not in the SceneDelegate? Like pass it in any View

My question is simple, since #EnvironmentObject can be used to share object between multiple view, and in almost all tutorials the #EnvironmentObject objects are all setup and passed in the SceneDelegate like this:
let userSettings = UserSettings()
let contentView = UserSettingsDemo().environmentObject(userSettings)
window.rootViewController = UIHostingController(rootView: contentView)
Can I pass my #EnvironmentObject object not in the SceneDelegate, but rather in any other View?
for example in UserSettingsDemo.swift:
struct UserSettingsDemo: View {
var userSettings: UserSettings
var body: some View {
VStack {
//this is child view, and it may have other child views inside it,
//Like I said, I pass the #EnvironmentObject userSettings here as the
//start point, not in the SceneDelegate, so that FancyScoreView's child views
//can use userSettings as #EnvironmentObject
FancyScoreView().environmentObject(userSettings)
}
}
}
Can I use #EnvironmentObject like above what I said?
The reason I'm asking this question is that in lots of scenarios we couldn't or it's not feasible to pass all we think it's 'Global' to SceneDelegate. Sometimes we can only get something that need to be global in the mid way. sometimes it's even a bad practice to pass all global stuff right in the start point of the app.
As .environmentObject modifier returns some View, so yes, you can.
/// Supplies an `ObservableObject` to a view subhierachy.
///
/// The object can be read by any child by using `EnvironmentObject`.
///
/// - Parameter bindable: the object to store and make available to
/// the view's subhiearchy.
#inlinable public func environmentObject<B>(_ bindable: B) -> some View where B : ObservableObject
struct ContentView: View {
var settings: UserSettings
var body: some View {
NavigationView {
VStack {
// A button that writes to the environment settings
Button(action: {
// Do something with settings
}) {
Text("Settings")
}
NavigationLink(destination: DetailView().environmentObject(settings)) {
Text("Show Detail View")
}
}
}
}
}
struct DetailView: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
// A text view that reads from the environment settings
Text("Some text")
}
}
As you can see, we didn’t need to explicitly associate the UserSettings instance in our scene delegate.
However #EnvironmentObject is used for data that should be shared with all views in your entire app. This lets us share model data, settings, theme, anywhere it’s needed, while also ensuring that our views automatically stay updated when that data changes.

Resources