In SwiftUI how do I update a Published property inside ViewModel1 from ViewModel2? - ios

Fairly new to SwiftUI and trying to figure out how to use ViewModels. Coming from UIKit I tend to like binding button presses to view model events, then apply the business logic and return a new value.
I am trying this in SwiftUI:
struct MainView: View {
#ObservedObject private var viewModel: MainViewModel
#State private var isShowingBottomSheet = false
var body: some View {
VStack {
Text("Hello \(viewModel.username)")
.font(.title)
Button("Show bottom sheet") {
isShowingBottomSheet = true
}
.sheet(isPresented: $isShowingBottomSheet) {
let viewModel = SheetViewModel()
viewModel.event.usernameUpdated
.assign(to: &$viewModel.username)
SheetView(viewModel: viewModel)
.presentationDetents([.fraction(0.15), .medium])
}
}
}
// MARK: - Initializers
init(viewModel: MainViewModel) {
self.viewModel = viewModel
}
}
With the view model:
final class MainViewModel: ObservableObject {
// MARK: - Properties
#Published var username = "John"
}
And SheetView:
struct SheetView: View {
#ObservedObject private var viewModel: SheetViewModel
var body: some View {
VStack {
Text("Some Sheet")
.font(.title)
Button("Change Name") {
viewModel.event.updateUsernameButtonTapped.send(())
}
}
}
// MARK: - Initializers
init(viewModel: SheetViewModel) {
self.viewModel = viewModel
}
}
And SheetViewModel:
final class SheetViewModel: ObservableObject {
// MARK: - Events
struct Event {
let updateUsernameButtonTapped = PassthroughSubject<Void, Never>()
let usernameUpdated = PassthroughSubject<String, Never>()
}
// MARK: - Properties
let event = Event()
private var cancellables = Set<AnyCancellable>()
// MARK: - Binding
private func bindEvents() {
event.updateUsernameButtonTapped
.map { "Sam" }
.sink { [weak self] name in
self?.event.usernameUpdated.send(name)
}
.store(in: &cancellables)
}
}
I am getting the error Cannot convert value of type 'Binding<String>' to expected argument type 'Published<String>.Publisher'. I want my SheetViewModel to update the value of #Published var username in the MainViewModel. How would I go about this?

We usually don't need view model objects in SwiftUI which has a design that benefits from value semantics, rather than the more error prone reference semantics of UIKit. If you want to move logic out of the View struct you can group related state vars and mutating funcs in their own struct, e.g.
struct ContentView: View {
#State var config = SheetConfig()
var body: some View {
VStack {
Text(config.text)
Button(action: show) {
Text("Edit Text")
}
}
.sheet(isPresented: $config.isShowing,
onDismiss: didDismiss) {
TextField("Text", $config.text)
}
}
func show() {
config.show(initialText: "Hello")
}
func didDismiss() {
// Handle the dismissing action.
}
}
struct SheetConfig {
var text = ""
var isShowing = false
mutating func show(initialText: String) {
text = initialText
isShowing = true
}
}
If you want to persist/sync data, or use Combine then you will need to resort to the reference type version of state which is #StateObject. However if you use the new async/await and .task then it's possible to still not need it.

Related

#Published property has a delay when used as navigationBar's title

I just noticed that when using a #Published property as navigation's title, there is some delay while displaying the value (but if the subscription is made on the ViewModel's init, it display instantly), but also the delay is only on the navigationBar, because the Text view display the value instantly:
class ContentViewModel: ObservableObject {
#Published var text: String = ""
private var cancellables = Set<AnyCancellable>()
func onAppear() {
Just("onAppear text")
.sink { [weak self] in
self?.text = $0
}
.store(in: &cancellables)
}
}
struct ContentView: View {
#ObservedObject var viewModel: ContentViewModel
var body: some View {
Text(viewModel.text)
.padding()
.navigationTitle(viewModel.text)
.onAppear(perform: viewModel.onAppear)
}
}
struct MainView: View {
#StateObject var viewModel = ContentViewModel()
var body: some View {
NavigationView {
NavigationLink("Open View") {
ContentView(viewModel: viewModel)
}
}
}
}
Note: Using the Toolbar also allows to display the title instantly:
.toolbar(content: {
ToolbarItemGroup(placement: .principal) {
Text(viewModel.text)
.fixedSize()
}
})
Note: But I minor drawback with the toolbar is that the text is not truncating, if I have a long text (as in my real app can be dynamic) is overflowing and showing below leading/trailing buttons. Probably is related to the fixedSize() (but if I don't apply, the View is not visible, looks like doesn't have the proper layout update)
in fact the lag exist, but you can use init instead of onAppear to load staff:
class ContentViewModel: ObservableObject {
#Published var text: String = ""
private var cancellables = Set<AnyCancellable>()
init() { // THIS
Just("onAppear text")
.sink { [weak self] in
self?.text = $0
}
.store(in: &cancellables)
}
}
struct TheView: View {
#ObservedObject var viewModel: ContentViewModel
var body: some View {
Text(viewModel.text)
.padding()
.navigationTitle(viewModel.text)
//.onAppear(perform: viewModel.onAppear) THIS
}
}

Computed Property from Child's ViewModel does not update #ObservedObject Parent's ViewModel

I have a parent view and a child view, each with their own viewModels. The parent view injects the child view's viewModel.
The parent view does not correctly react to the changes on the child's computed property isFormInvalid (the child view does).
#Published cannot be added to a computed property, and other questions/answers I've seen around that area have not focused on having separate viewModels as this question does. I want separate viewModels to increase testability, since the child view could become quite a complex form.
Here is a file to minimally reproduce the issue:
import SwiftUI
extension ParentView {
final class ViewModel: ObservableObject {
#ObservedObject var childViewViewModel: ChildView.ViewModel
init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
self.childViewViewModel = childViewViewModel
}
}
}
struct ParentView: View {
#ObservedObject private var viewModel: ViewModel
init(viewModel: ViewModel = ViewModel()) {
self.viewModel = viewModel
}
var body: some View {
ChildView(viewModel: viewModel.childViewViewModel)
.navigationBarTitle("Form", displayMode: .inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
addButton
}
}
}
private var addButton: some View {
Button {
print("======")
print(viewModel.childViewViewModel.$name)
} label: {
Text("ParentIsValid?")
}
.disabled(viewModel.childViewViewModel.isFormInvalid) // FIXME: doesn't work, but the actual fields work in terms of two way updating
}
}
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
let childVm = ChildView.ViewModel()
let vm = ParentView.ViewModel(childViewViewModel: childVm)
NavigationView {
ParentView(viewModel: vm)
}
}
}
// MARK: child view
extension ChildView {
final class ViewModel: ObservableObject {
// MARK: - public properties
#Published var name = ""
var isFormInvalid: Bool {
print("isFormInvalid")
return name.isEmpty
}
}
}
struct ChildView: View {
#ObservedObject private var viewModel: ViewModel
init(viewModel: ViewModel = ViewModel()) {
self.viewModel = viewModel
}
var body: some View {
Form {
Section(header: Text("Name")) {
nameTextField
}
Button {} label: {
Text("ChildIsValid?: \(String(!viewModel.isFormInvalid))")
}
.disabled(viewModel.isFormInvalid)
}
}
private var nameTextField: some View {
TextField("Add name", text: $viewModel.name)
.autocapitalization(.words)
}
}
struct ChildView_Previews: PreviewProvider {
static var previews: some View {
let vm = ChildView.ViewModel()
ChildView(viewModel: vm).preferredColorScheme(.light)
}
}
Thank you!
Computed properties do not trigger any updates. It is the changed to #Publised property that triggers an update, when that happens the computed property is reevaluated. This works as expected which you can see in your ChildView. The problem you are facing is that ObservableObjects are not really designed for chaining (updated to child does not trigger update to the parent. You can workaround the fact by republishing an update from the child: you have subscribe to child's objectWillChange and each time it emits manually trigger objectWillChange on the parent.
extension ParentView {
final class ViewModel: ObservableObject {
#ObservedObject var childViewViewModel: ChildView.ViewModel
private var cancellables = Set<AnyCancellable>()
init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
self.childViewViewModel = childViewViewModel
childViewViewModel
.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}
}

How can I dynamically build a View for SwiftUI and present it?

I've included stubbed code samples. I'm not sure how to get this presentation to work. My expectation is that when the sheet presentation closure is evaluated, aDependency should be non-nil. However, what is happening is that aDependency is being treated as nil, and TheNextView never gets put on screen.
How can I model this such that TheNextView is shown? What am I missing here?
struct ADependency {}
struct AModel {
func buildDependencyForNextExperience() -> ADependency? {
return ADependency()
}
}
struct ATestView_PresentationOccursButNextViewNotShown: View {
#State private var aDependency: ADependency?
#State private var isPresenting = false
#State private var wantsPresent = false {
didSet {
aDependency = model.buildDependencyForNextExperience()
isPresenting = true
}
}
private let model = AModel()
var body: some View {
Text("Tap to present")
.onTapGesture {
wantsPresent = true
}
.sheet(isPresented: $isPresenting, content: {
if let dependency = aDependency {
// Never executed
TheNextView(aDependency: dependency)
}
})
}
}
struct TheNextView: View {
let aDependency: ADependency
init(aDependency: ADependency) {
self.aDependency = aDependency
}
var body: some View {
Text("Next Screen")
}
}
This is a common problem in iOS 14. The sheet(isPresented:) gets evaluated on first render and then does not correctly update.
To get around this, you can use sheet(item:). The only catch is your item has to conform to Identifiable.
The following version of your code works:
struct ADependency : Identifiable {
var id = UUID()
}
struct AModel {
func buildDependencyForNextExperience() -> ADependency? {
return ADependency()
}
}
struct ContentView: View {
#State private var aDependency: ADependency?
private let model = AModel()
var body: some View {
Text("Tap to present")
.onTapGesture {
aDependency = model.buildDependencyForNextExperience()
}
.sheet(item: $aDependency, content: { (item) in
TheNextView(aDependency: item)
})
}
}

Using #StateObject in iOS 14.0 while supporting iOS 13.0

I need help finding the best way to support the new #StateObject in iOS 14.0 and still supporting some alternative in iOS 13.0. Admittedly, I do not know what is the best approach in iOS 13.0. Below is what I currently have.
Does anyone have ideas on a better approach?
struct HomeView: View {
let viewModel: HomeViewModel
var body: some View {
if #available(iOS 14, *) {
HomeViewWrapper(viewModel: viewModel)
} else {
CompatibleHomeViewWrapper(viewModel: viewModel)
}
}
}
#available(iOS 14, *)
private struct HomeViewWrapper: View {
#StateObject var viewModel: HomeViewModel
var body: some View {
CompatibleHomeView(viewModel: viewModel)
}
}
private struct CompatibleHomeViewWrapper: View {
#State var viewModel: HomeViewModel
var body: some View {
CompatibleHomeView(viewModel: viewModel)
}
}
struct CompatibleHomeView: View {
#ObservedObject var viewModel: HomeViewModel
var body: some View {
Text(viewModel.someRandomName)
}
}
You can get the #StateObject behaviour by wrapping a custom propertyWrapper around #State and #ObservedObject like so:
import Combine
import PublishedObject // https://github.com/Amzd/PublishedObject
/// A property wrapper type that instantiates an observable object.
#propertyWrapper
public struct StateObject<ObjectType: ObservableObject>
where ObjectType.ObjectWillChangePublisher == ObservableObjectPublisher {
/// Wrapper that helps with initialising without actually having an ObservableObject yet
private class ObservedObjectWrapper: ObservableObject {
#PublishedObject var wrappedObject: ObjectType? = nil
init() {}
}
private var thunk: () -> ObjectType
#ObservedObject private var observedObject = ObservedObjectWrapper()
#State private var state = ObservedObjectWrapper()
public var wrappedValue: ObjectType {
if state.wrappedObject == nil {
// There is no State yet so we need to initialise the object
state.wrappedObject = thunk()
}
if observedObject.wrappedObject == nil {
// Retrieve the object from State and observe it in ObservedObject
observedObject.wrappedObject = state.wrappedObject
}
return state.wrappedObject!
}
public init(wrappedValue thunk: #autoclosure #escaping () -> ObjectType) {
self.thunk = thunk
}
}
I use this myself too so I will keep it updated at:
https://gist.github.com/Amzd/8f0d4d94fcbb6c9548e7cf0c1493eaff
Note: The most upvoted comment was that ObservedObject was very similar, which is just not true at all.
StateObject retains the object between view inits AND relays object changes to the view through willChangeObserver.
ObservedObject only relays changes to the view BUT if you create it in the view init, every time the parent view changes the object will be initialised again (losing your state).
This explanation is very crude and there is better out there please read up on it as it is an impactful part of SwiftUI.
struct StateObjectView<ViewModel: ObservableObject, Content: View>: View {
let viewModel: ViewModel
let content: () -> Content
var body: some View {
VStack {
if #available(iOS 14, *) {
StateObjectView14(viewModel: viewModel, content: content)
} else {
StateObjectView13(viewModel: viewModel, content: content)
}
}
}
}
#available(iOS 14.0, *)
struct StateObjectView14<ViewModel: ObservableObject, Content: View>: View {
#SwiftUI.StateObject var viewModel : ViewModel
let content: () -> Content
var body: some View {
content()
.environmentObject(viewModel)
}
}
struct StateObjectView13<ViewModel: ObservableObject, Content: View>: View {
#Backport.StateObject var viewModel : ViewModel
let content: () -> Content
var body: some View {
content()
.environmentObject(viewModel)
}
}
Usage:
struct ContentView: View {
#State private var reset = false
var body: some View {
VStack {
Button("reset") {
reset.toggle()
}
ScoreView()
}
}
}
class ScoreViewModel: ObservableObject {
init() {
//score = 0
print("Model Created")
}
#Published var score: Int = 0
}
struct ScoreView: View {
let viewModel : ScoreViewModel = .init()
var body: some View {
StateObjectView(viewModel: viewModel) {
ScoreContentView()
}
.onAppear {
print("ScoreView Appear")
}
}
}
struct ScoreContentView: View {
//#ObservedObject var viewModel : ScoreViewModel = .init()
#EnvironmentObject var viewModel : ScoreViewModel
#State private var niceScore = false
var body: some View {
VStack {
Button("Add Score") {
viewModel.score += 1
print("viewModel.score: \(viewModel.score)")
if viewModel.score > 3 {
niceScore = true
}
}
Text("Content Score: \(viewModel.score)")
Text("Nice? \(niceScore ? "YES" : "NO")")
}
.padding()
.background(Color.red)
}
}
Backports:
https://github.com/shaps80/SwiftUIBackports

onReceive not getting called in SwiftUI View when ObservedObject changes

I don't manage to trigger the onReceive method in a SwiftUI View whenever a variable from ObservedObject changes.
I tried two methods: using #Publish and using PassthroughSubject<>
Here is the ViewModel
class MenuViewModel: ObservableObject {
#Published var selectedItems = Set<UUID>()
#Published var currentFocusItem: UUID?
// Output
let newItemOnFocus = PassthroughSubject<(UUID?), Never>()
// This function gets called good :)
func tapOnMenuItem(_ item: MenuItem) {
if selectedItems.contains(item.id) {
//These changes should trigger the onReceive?
currentFocusItem = item.id
newItemOnFocus.send(item.id)
} else {
selectedItems.insert(item.id)
currentFocusItem = nil
newItemOnFocus.send(nil)
}
}
}
Here is the View when trying to catch the changes in #Published var currentFocusItem
struct MenuView: View {
#ObservedObject private var viewModel: MenuViewModel
#State var showPicker = false
#State private var menu: Menu = Menu.mockMenu()
init(viewModel: MenuViewModel = MenuViewModel()) {
self.viewModel = viewModel
}
var body: some View {
VStack {
List(menu.items, selection: $viewModel.selectedItems) { item in
MenuItemView(item: item)
}
Divider()
getBottomView(showPicker: showPicker)
}
.navigationBarTitle("Title")
.navigationBarItems(trailing: Button(action: closeModal) {
Image(systemName: "xmark")
})
.onReceive(viewModel.$currentFocusItem, perform: { itemUUID in
self.showPicker = itemUUID != nil // <-- This only gets called at launch time
})
}
}
The View in the same way but trying to catch the PassthroughSubject<>
.onReceive(viewModel.newItemOnFocus, perform: { itemUUID in
self.showPicker = itemUUID != nil // <-- This never gets called
})
----------EDIT----------
Adding MenuItemView, although viewModel.tapOnMenuItem gets always called, so I am not sure if it's very relevant
MenuItemView is here:
struct MenuItemView: View {
var item: MenuItem
#ObservedObject private var viewModel: MenuViewModel = MenuViewModel()
#State private var isSelected = false
var body: some View {
HStack(spacing: 24) {
Text(isSelected ? " 1 " : item.icon)
.font(.largeTitle)
.foregroundColor(.blue)
.bold()
VStack(alignment: .leading, spacing: 12) {
Text(item.name)
.bold()
Text(item.description)
.font(.callout)
}
Spacer()
Text("\(item.points)\npoints")
.multilineTextAlignment(.center)
}
.padding()
.onTapGesture {
self.isSelected = true
self.viewModel.tapOnMenuItem(self.item). // <-- Here tapOnMenuItem gets called
}
}
func quantityText(isItemSelected: Bool) -> String {
return isItemSelected ? "1" : item.icon
}
}
What am I doing wrong?
Well, here it is - your MenuView and MenuItemView use different instances of view model
1)
struct MenuView: View {
#ObservedObject private var viewModel: MenuViewModel
#State var showPicker = false
#State private var menu: Menu = Menu.mockMenu()
init(viewModel: MenuViewModel = MenuViewModel()) { // 1st one created
2)
struct MenuItemView: View {
var item: MenuItem
#ObservedObject private var viewModel: MenuViewModel = MenuViewModel() // 2nd one
thus, you modify one instance, but subscribe to changes in another one. That's it.
Solution: pass view model via .environmentObject or via argument from MenuView to MenuItemView.

Resources