Async update in MVVM and animation on state change - ios

I trying to understand how Combine and SwiftUI works in combination with MVVM and clean architecture, but I encountered a problem with using withAnimation once my view model has an async method that updated published value. I was able to solve it, but I'm pretty sure it's not the correct way and I'm missing something fundamental. Here it is how it looks my solution, starting with my data manager:
protocol NameManaging {
var publisher: AnyPublisher<[Name], Never> { get }
func fetchNames() async
}
class MockNameManager: NameManaging {
var publisher: AnyPublisher<[Name], Never> {
names.eraseToAnyPublisher()
}
func fetchNames() async {
var values = await heavyAsyncTask()
names.value.append(contentsOf: values)
}
private func heavyAsyncTask() async -> [Name] {
// do some heavy async task
}
private var names = CurrentValueSubject<[Name], Never>([])
}
Then view models:
class NameListViewModel: ObservableObject {
#Published var names = [Name]()
private var anyCancellable: AnyCancellable?
private var nameManager: NameManaging
init(nameManager: NameManaging = MockNameManager()) {
self.nameManager = nameManager
self.anyCancellable = nameManager.publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] values in
withAnimation {
self?.names = values
}
})
}
func fetchNames() async {
await nameManager.fetchNames()
}
}
Lastly my view:
struct NameList: View {
#StateObject private var nameListViewModel = NameListViewModel()
var body: some View {
VStack {
VStack {
HStack {
Button(action: updateNames) {
Text("Fetch some more names")
}
}
}
.padding()
List {
ForEach(nameListViewModel.names) {
NameRow(name: $0)
}
}
}
.navigationTitle("Names list")
.onAppear(perform: updateNames)
}
func updateNames() {
Task {
await nameListViewModel.fetchNames()
}
}
}
What I did is use withAnimation inside my view model in .sink() method of data manager publisher. This works as expected, but it indroduce view function inside view model. How can I do it in a way that inside updateNames in my view I'll use withAnimation? Or maybe I should do it in completely different way?

You are mixing up technologies. The point of async/await is to remove the need for a state object (i.e. a reference type) and Combine to do async work. You can simply use the .task modifier to call any async func and set the result on an #State var. If the async func throws then you might catch the exception and set a message on another #State var. The great thing about .task is it's called when the UIView (that SwiftUI creates for you) appears and cancelled when it disappears (also if the optional id param changes). So no need for an object, which often is the cause of consistency/memory problems which Swift and SwiftUI's use of value types is designed to eliminate.
struct NameList: View {
#State var var names: [Name] = []
#State var fetchCount = 0
var body: some View {
VStack {
VStack {
HStack {
Button("Fetch some more names") {
fetchCount += 1
}
}
}
.padding()
List {
ForEach(names) { name in
NameRow(name: name)
}
}
}
.navigationTitle("Names list")
.task(id: fetchCount) {
let names = await Name.fetchNames()
withAnimation {
self.names = names
}
}
}
}

Related

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

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.

Perform action when user change Picker value

I have a piece of sample code that shows picker. It is a simplified version of project that I'm working on. I have a view model that can be updated externally (via bluetooth - in example it's simulated) and by user using Picker. I would like to perform an action (for example an update) when user changes the value. I used onChange event on binding that is set on Picker and it works but the problem is that it also is called when value is changed externally which I don't want. Does anyone knows how to make it work as I expect?
enum Type {
case Type1, Type2
}
class ViewModel: ObservableObject {
#Published var type = Type.Type1
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.type = Type.Type2
}
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Row(type: $viewModel.type) {
self.update()
}
}
}
func update() {
// some update logic that should be called only when user changed value
print("update")
}
}
struct Row: View {
#Binding var type: Type
var action: () -> Void
var body: some View {
Picker("Type", selection: $type) {
Text("Type1").tag(Type.Type1)
Text("Type2").tag(Type.Type2)
}
.onChange(of: type, perform: { newType in
print("changed: \(newType)")
action()
})
}
}
EDIT:
I found a solution but I'm not sure if it's good one. I had to use custom binding like this:
struct Row: View {
#Binding var type: Type
var action: () -> Void
var body: some View {
let binding = Binding(
get: { self.type },
set: { self.type = $0
action()
}
)
return Picker("Type", selection: binding) {
Text("Type1").tag(Type.Type1)
Text("Type2").tag(Type.Type2)
}
}
}

SwiftUI: how to make network request when value of binding changes?

I'm trying to update my chart view when I swipe to another page in PagerView. When I swipe currentIndex changes. But I don't understand how to get notified when currentIndex changes.
Here is my code:
struct MainView: View {
let network = Network()
#State private var currentIndex: Int = 0
#State private var sources: [Source] = []
var body: some View {
return ZStack {
...
VStack {
Text("Температура")
.defaultFont(font: .system(size: 30), weight: .regular)
PagerView(pagesCount: self.sources.count, currentIndex: self.$currentIndex) {
ForEach(self.sources, id: \.self) { t in
...
}
}
if !sources.isEmpty {
ChartView(sourceId: $sources[currentIndex].id)
} else {
Spacer()
}
}
}
}
}
PagerView binds to currentIndex so when I swipe a page currentIndex changes. ChartView has a method loadData and I want to call it when I swipe a page to load new chart depends on sources[currentIndex].id. Here is code of ChartView:
struct ChartView: View {
#Binding var sourceId: String {
didSet {
loadData()
}
}
#State private var points: [TemperaturePoint] = []
var body: some View {
GeometryReader { proxy in
ChartView.makePath(by: self.points, with: proxy)
.stroke(Color.white, lineWidth: 2)
}.onAppear(perform: loadData)
}
func loadData() {
network.getPoints(sourceId: sourceId) { response in
switch response {
case .result(let array):
self.points = TemperaturePoint.smooth(points: array.results)
case .error(let error):
print(error)
}
}
}
}
So question is how to make network calling when Binding variable changes? Or maybe I mistake and need to use another way to code this.
When bound state is changed only body of view with #Binding is called, ie. actually only refresh happens, and only of part dependent of bound state, so even .onAppear is not called.
Taking about into account and the fact that entire ChartView depends on new identifier, the solution would be to make force refresh view as whole, so .onAppear called again, and loaded new data.
Here is code. Tested with Xcode 11.4 / iOS 13.4 (with some simplified replicated version).
struct ChartView: View {
#Binding var sourceId: String // << just binding
#State private var points: [TemperaturePoint] = []
var body: some View {
GeometryReader { proxy in
ChartView.makePath(by: self.points, with: proxy)
.stroke(Color.white, lineWidth: 2)
}
.id(sourceId) // << here !!
.onAppear(perform: loadData)
}
func loadData() {
network.getPoints(sourceId: sourceId) { response in
switch response {
case .result(let array):
self.points = TemperaturePoint.smooth(points: array.results)
case .error(let error):
print(error)
}
}
}
}

How to tell SwiftUI views to bind to nested ObservableObjects

I have a SwiftUI view that takes in an EnvironmentObject called appModel. It then reads the value appModel.submodel.count in its body method. I expect this to bind my view to the property count on submodel so that it re-renders when the property updates, but this does not seem to happen.
Is this a bug? And if not, what is the idiomatic way to have views bind to nested properties of environment objects in SwiftUI?
Specifically, my model looks like this...
class Submodel: ObservableObject {
#Published var count = 0
}
class AppModel: ObservableObject {
#Published var submodel: Submodel = Submodel()
}
And my view looks like this...
struct ContentView: View {
#EnvironmentObject var appModel: AppModel
var body: some View {
Text("Count: \(appModel.submodel.count)")
.onTapGesture {
self.appModel.submodel.count += 1
}
}
}
When I run the app and click on the label, the count property does increase but the label does not update.
I can fix this by passing in appModel.submodel as a property to ContentView, but I'd like to avoid doing so if possible.
Nested models does not work yet in SwiftUI, but you could do something like this
class SubModel: ObservableObject {
#Published var count = 0
}
class AppModel: ObservableObject {
#Published var submodel: SubModel = SubModel()
var anyCancellable: AnyCancellable? = nil
init() {
anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}
}
}
Basically your AppModel catches the event from SubModel and send it further to the View.
Edit:
If you do not need SubModel to be class, then you could try something like this either:
struct SubModel{
var count = 0
}
class AppModel: ObservableObject {
#Published var submodel: SubModel = SubModel()
}
Sorin Lica's solution can solve the problem but this will result in code smell when dealing with complicated views.
What seems to better advice is to look closely at your views, and revise them to make more, and more targeted views. Structure your views so that each view displays a single level of the object structure, matching views to the classes that conform to ObservableObject. In the case above, you could make a view for displaying Submodel (or even several views) that display's the property from it that you want show. Pass the property element to that view, and let it track the publisher chain for you.
struct ContentView: View {
#EnvironmentObject var appModel: AppModel
var body: some View {
SubView(submodel: appModel.submodel)
}
}
struct SubView: View {
#ObservedObject var submodel: Submodel
var body: some View {
Text("Count: \(submodel.count)")
.onTapGesture {
self.submodel.count += 1
}
}
}
This pattern implies making more, smaller, and focused views, and lets the engine inside SwiftUI do the relevant tracking. Then you don't have to deal with the book keeping, and your views potentially get quite a bit simpler as well.
You can check for more detail in this post: https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/
I wrote about this recently on my blog: Nested Observable Objects. The gist of the solution, if you really want a hierarchy of ObservableObjects, is to create your own top-level Combine Subject to conform to the ObservableObject protocol, and then encapsulate any logic of what you want to trigger updates into imperative code that updates that subject.
For example, if you had two "nested" classes, such as
class MainThing : ObservableObject {
#Published var element : SomeElement
init(element : SomeElement) {
self.element = element
}
}
class SomeElement : ObservableObject {
#Published var value : String
init(value : String) {
self.value = value
}
}
Then you could expand the top-level class (MainThing in this case) to:
class MainThing : ObservableObject {
#Published var element : SomeElement
var cancellable : AnyCancellable?
init(element : SomeElement) {
self.element = element
self.cancellable = self.element.$value.sink(
receiveValue: { [weak self] _ in
self?.objectWillChange.send()
}
)
}
}
Which grabs a publisher from the embedded ObservableObject, and sends an update into the local published when the property value on SomeElement class is modified. You can extend this to use CombineLatest for publishing streams from multiple properties, or any number of variations on the theme.
This isn't a "just do it" solution though, because the logical conclusion of this pattern is after you've grown that hierarchy of views, you're going to end up with potentially huge swatches of a View subscribed to that publisher that will invalidate and redraw, potentially causing excessive, sweeping redraws and relatively poor performance on updates. I would advise seeing if you can refactor your views to be specific to a class, and match it to just that class, to keep the "blast radius" of SwiftUI's view invalidation minimized.
#Published is not designed for reference types so it's a programming error to add it on the AppModel property, even though the compiler or runtime doesn't complain. What would've been intuitive is adding #ObservedObject like below but sadly this silently does nothing:
class AppModel: ObservableObject {
#ObservedObject var submodel: SubModel = SubModel()
}
I'm not sure if disallowing nested ObservableObjects was intentional by SwiftUI or a gap to be filled in the future. Wiring up the parent and child objects as suggested in the other answers is very messy and hard to maintain. What seems to be the idea of SwiftUI is to split up the views into smaller ones and pass the child object to the subview:
struct ContentView: View {
#EnvironmentObject var appModel: AppModel
var body: some View {
SubView(model: appModel.submodel)
}
}
struct SubView: View {
#ObservedObject var model: SubModel
var body: some View {
Text("Count: \(model.count)")
.onTapGesture {
model.count += 1
}
}
}
class SubModel: ObservableObject {
#Published var count = 0
}
class AppModel: ObservableObject {
var submodel: SubModel = SubModel()
}
The submodel mutations actually propagate when passing into a subview!
However, there's nothing stopping another dev from calling appModel.submodel.count from the parent view which is annoying there's no compiler warning or even some Swift way to enforce not doing this.
Source: https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/
If you need to nest observable objects here is the best way to do it that I could find.
class ChildModel: ObservableObject {
#Published
var count = 0
}
class ParentModel: ObservableObject {
#Published
private var childWillChange: Void = ()
let child = ChildModel()
init() {
child.objectWillChange.assign(to: &$childWillChange)
}
}
Instead of subscribing to child's objectWillChange publisher and firing parent's publisher, you assign values to published property and parent's objectWillChange triggers automatically.
All three ViewModels can communicate and update
// First ViewModel
class FirstViewModel: ObservableObject {
var facadeViewModel: FacadeViewModels
facadeViewModel.firstViewModelUpdateSecondViewModel()
}
// Second ViewModel
class SecondViewModel: ObservableObject {
}
// FacadeViewModels Combine Both
import Combine // so you can update thru nested Observable Objects
class FacadeViewModels: ObservableObject {
lazy var firstViewModel: FirstViewModel = FirstViewModel(facadeViewModel: self)
#Published var secondViewModel = secondViewModel()
}
var anyCancellable = Set<AnyCancellable>()
init() {
firstViewModel.objectWillChange.sink {
self.objectWillChange.send()
}.store(in: &anyCancellable)
secondViewModel.objectWillChange.sink {
self.objectWillChange.send()
}.store(in: &anyCancellable)
}
func firstViewModelUpdateSecondViewModel() {
//Change something on secondViewModel
secondViewModel
}
Thank you Sorin for Combine solution.
I have a solution that I believe is more ellegant than subscribing to the child (view)models. It's weird and I don't have an explanation for why it works.
Solution
Define a base class that inherits from ObservableObject, and defines a method notifyWillChange() that simply calls objectWillChange.send(). Any derived class then overrides notifyWillChange() and calls the parent's notifyWillChange() method.
Wrapping objectWillChange.send() in a method is required, otherwise the changes to #Published properties do not cause the any Views to update. It may have something to do with how #Published changes are detected. I believe SwiftUI/Combine use reflection under the hood...
I have made some slight additions to OP's code:
count is wrapped in a method call which calls notifyWillChange() before the counter is incremented. This is required for the propagation of the changes.
AppModel contains one more #Published property, title, which is used for the navigation bar's title. This showcases that #Published works for both the parent object and the child (in the example below, updated 2 seconds after the model is initialized).
Code
Base Model
class BaseViewModel: ObservableObject {
func notifyWillUpdate() {
objectWillChange.send()
}
}
Models
class Submodel: BaseViewModel {
#Published var count = 0
}
class AppModel: BaseViewModel {
#Published var title: String = "Hello"
#Published var submodel: Submodel = Submodel()
override init() {
super.init()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self = self else { return }
self.notifyWillChange() // XXX: objectWillChange.send() doesn't work!
self.title = "Hello, World"
}
}
func increment() {
notifyWillChange() // XXX: objectWillChange.send() doesn't work!
submodel.count += 1
}
override func notifyWillChange() {
super.notifyWillChange()
objectWillChange.send()
}
}
The View
struct ContentView: View {
#EnvironmentObject var appModel: AppModel
var body: some View {
NavigationView {
Text("Count: \(appModel.submodel.count)")
.onTapGesture {
self.appModel.increment()
}.navigationBarTitle(appModel.title)
}
}
}
I liked solution by sorin-lica. Based upon that I've decided to implement a custom Property Wrapper (following this amazing article) named NestedObservableObject to make that solution more developer friendly.
This allow to write your model in the following way
class Submodel: ObservableObject {
#Published var count = 0
}
class AppModel: ObservableObject {
#NestedObservableObject var submodel: Submodel = Submodel()
}
Property Wrapper implementation
#propertyWrapper
struct NestedObservableObject<Value : ObservableObject> {
static subscript<T: ObservableObject>(
_enclosingInstance instance: T,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
) -> Value {
get {
if instance[keyPath: storageKeyPath].cancellable == nil, let publisher = instance.objectWillChange as? ObservableObjectPublisher {
instance[keyPath: storageKeyPath].cancellable =
instance[keyPath: storageKeyPath].storage.objectWillChange.sink { _ in
publisher.send()
}
}
return instance[keyPath: storageKeyPath].storage
}
set {
if let cancellable = instance[keyPath: storageKeyPath].cancellable {
cancellable.cancel()
}
if let publisher = instance.objectWillChange as? ObservableObjectPublisher {
instance[keyPath: storageKeyPath].cancellable =
newValue.objectWillChange.sink { _ in
publisher.send()
}
}
instance[keyPath: storageKeyPath].storage = newValue
}
}
#available(*, unavailable,
message: "This property wrapper can only be applied to classes"
)
var wrappedValue: Value {
get { fatalError() }
set { fatalError() }
}
private var cancellable: AnyCancellable?
private var storage: Value
init(wrappedValue: Value) {
storage = wrappedValue
}
}
I've published code on gist
I do it like this:
import Combine
extension ObservableObject {
func propagateWeakly<InputObservableObject>(
to inputObservableObject: InputObservableObject
) -> AnyCancellable where
InputObservableObject: ObservableObject,
InputObservableObject.ObjectWillChangePublisher == ObservableObjectPublisher
{
objectWillChange.propagateWeakly(to: inputObservableObject)
}
}
extension Publisher where Failure == Never {
public func propagateWeakly<InputObservableObject>(
to inputObservableObject: InputObservableObject
) -> AnyCancellable where
InputObservableObject: ObservableObject,
InputObservableObject.ObjectWillChangePublisher == ObservableObjectPublisher
{
sink { [weak inputObservableObject] _ in
inputObservableObject?.objectWillChange.send()
}
}
}
So on the call side:
class TrackViewModel {
private let playbackViewModel: PlaybackViewModel
private var propagation: Any?
init(playbackViewModel: PlaybackViewModel) {
self.playbackViewModel = playbackViewModel
propagation = playbackViewModel.propagateWeakly(to: self)
}
...
}
Here's a gist.
See following post for a solution: [arthurhammer.de/2020/03/combine-optional-flatmap][1] . This is solving the question in a Combine-Way with the $ publisher.
Assume class Foto has an annotation struct and and annotation publisher, which publish an annotation struct. Within Foto.sample(orientation: .Portrait) the annotation struct gets "loaded" through the annotation publisher asynchroniously. Plain vanilla combine.... but to get that into a View & ViewModel, use this:
class DataController: ObservableObject {
#Published var foto: Foto
#Published var annotation: LCPointAnnotation
#Published var annotationFromFoto: LCPointAnnotation
private var cancellables: Set<AnyCancellable> = []
init() {
self.foto = Foto.sample(orientation: .Portrait)
self.annotation = LCPointAnnotation()
self.annotationFromFoto = LCPointAnnotation()
self.foto.annotationPublisher
.replaceError(with: LCPointAnnotation.emptyAnnotation)
.assign(to: \.annotation, on: self)
.store(in: &cancellables)
$foto
.flatMap { $0.$annotation }
.replaceError(with: LCPointAnnotation.emptyAnnotation)
.assign(to: \.annotationFromFoto, on: self)
.store(in: &cancellables)
}
}
Note: [1]: https://arthurhammer.de/2020/03/combine-optional-flatmap/
Pay attention the $annotation above within the flatMap, it's a publisher!
public class Foto: ObservableObject, FotoProperties, FotoPublishers {
/// use class not struct to update asnyc properties!
/// Source image data
#Published public var data: Data
#Published public var annotation = LCPointAnnotation.defaultAnnotation
......
public init(data: Data) {
guard let _ = UIImage(data: data),
let _ = CIImage(data: data) else {
fatalError("Foto - init(data) - invalid Data to generate CIImage or UIImage")
}
self.data = data
self.annotationPublisher
.replaceError(with: LCPointAnnotation.emptyAnnotation)
.sink {resultAnnotation in
self.annotation = resultAnnotation
print("Foto - init annotation = \(self.annotation)")
}
.store(in: &cancellables)
}
You can create a var in your top view that is equal to a function or published var in your top class. Then pass it and bind it to every sub view. If it changes in any sub view then the top view will be updated.
Code Structure:
struct Expense : Identifiable {
var id = UUID()
var name: String
var type: String
var cost: Double
var isDeletable: Bool
}
class Expenses: ObservableObject{
#Published var name: String
#Published var items: [Expense]
init() {
name = "John Smith"
items = [
Expense(name: "Lunch", type: "Business", cost: 25.47, isDeletable: true),
Expense(name: "Taxi", type: "Business", cost: 17.0, isDeletable: true),
Expense(name: "Sports Tickets", type: "Personal", cost: 75.0, isDeletable: false)
]
}
func totalExpenses() -> Double { }
}
class ExpenseTracker: ObservableObject {
#Published var name: String
#Published var expenses: Expenses
init() {
name = "My name"
expenses = Expenses()
}
func getTotalExpenses() -> Double { }
}
Views:
struct MainView: View {
#ObservedObject var myTracker: ExpenseTracker
#State var totalExpenses: Double = 0.0
var body: some View {
NavigationView {
Form {
Section (header: Text("Main")) {
HStack {
Text("name:")
Spacer()
TextField("", text: $myTracker.name)
.multilineTextAlignment(.trailing)
.keyboardType(.default)
}
NavigationLink(destination: ContentView(myExpenses: myTracker.expenses, totalExpenses: $totalExpenses),
label: {
Text("View Expenses")
})
}
Section (header: Text("Results")) {
}
HStack {
Text("Total Expenses")
Spacer()
Text("\(totalExpenses, specifier: "%.2f")")
}
}
}
.navigationTitle("My Expense Tracker")
.font(.subheadline)
}
.onAppear{
totalExpenses = myTracker.getTotalExpenses()
}
}
}
struct ContentView: View {
#ObservedObject var myExpenses:Expenses
#Binding var totalExpenses: Double
#State var selectedExpenseItem:Expense? = nil
var body: some View {
NavigationView{
Form {
List {
ForEach(myExpenses.items) { item in
HStack {
Text("\(item.name)")
Spacer()
Button(action: {
self.selectedExpenseItem = item
} ) {
Text("View")
}
}
.deleteDisabled(item.isDeletable)
}
.onDelete(perform: removeItem)
}
HStack {
Text("Total Expenses:")
Spacer()
Text("\(myExpenses.totalExpenses(), specifier: "%.2f")")
}
}
.navigationTitle("Expenses")
.toolbar {
Button {
let newExpense = Expense(name: "Enter name", type: "Expense item", cost: 10.00, isDeletable: false)
self.myExpenses.items.append(newExpense)
self.totalExpenses = myExpenses.totalExpenses()
} label: {
Image(systemName: "plus")
}
}
}
.fullScreenCover(item: $selectedExpenseItem) { myItem in
ItemDetailView(item: myItem, myExpenses: myExpenses, totalExpenses: $totalExpenses)
}
}
func removeItem(at offsets: IndexSet){
self.myExpenses.items.remove(atOffsets: offsets)
self.totalExpenses = myExpenses.totalExpenses()
}
}
Just noting that I'm using the NestedObservableObject approach from #bsorrentino in my latest app.
Normally I'd avoid this but the nested object in question is actually a CoreData model so breaking things out into smaller views doesn't really work in this regard.
This solution seemed best since the world treats NSManagedObjects as (mostly) ObservableObjects and I really, really need to trigger an update if the CodeData object model is changed down the line.
The var submodel in AppModel doesn't need the property wrapper #Published.
The purpose of #Published is to emit new values and objectWillChange.
But the variable is never changed but only initiated once.
Changes in submodel are propagated to the view by the subscriber anyCancellable and ObservableObject-protocol via the sink-objectWillChange construction and causes a View to redraw.
class SubModel: ObservableObject {
#Published var count = 0
}
class AppModel: ObservableObject {
let submodel = SubModel()
var anyCancellable: AnyCancellable? = nil
init() {
anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}
}
}
Nested ObservableObject models do not work yet.
However, you can make it work by manually subscribing each model. The answer gave a simple example of this.
I wanted to add that you can make this manual process a bit more streamlined & readable via extensions:
class Submodel: ObservableObject {
#Published var count = 0
}
class AppModel: ObservableObject {
#Published var submodel = Submodel()
#Published var submodel2 = Submodel2() // the code for this is not defined and is for example only
private var cancellables: Set<AnyCancellable> = []
init() {
// subscribe to changes in `Submodel`
submodel
.subscribe(self)
.store(in: &cancellables)
// you can also subscribe to other models easily (this solution scales well):
submodel2
.subscribe(self)
.store(in: &cancellables)
}
}
Here is the extension:
extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher {
func subscribe<T: ObservableObject>(
_ observableObject: T
) -> AnyCancellable where T.ObjectWillChangePublisher == ObservableObjectPublisher {
return objectWillChange
// Publishing changes from background threads is not allowed.
.receive(on: DispatchQueue.main)
.sink { [weak observableObject] (_) in
observableObject?.objectWillChange.send()
}
}
}
It looks like bug. When I update the xcode to the latest version, it work correctly when binding to nested ObservableObjects

SwiftUI View - viewDidLoad()?

Trying to load an image after the view loads, the model object driving the view (see MovieDetail below) has a urlString. Because a SwiftUI View element has no life cycle methods (and there's not a view controller driving things) what is the best way to handle this?
The main issue I'm having is no matter which way I try to solve the problem (Binding an object or using a State variable), my View doesn't have the urlString until after it loads...
// movie object
struct Movie: Decodable, Identifiable {
let id: String
let title: String
let year: String
let type: String
var posterUrl: String
private enum CodingKeys: String, CodingKey {
case id = "imdbID"
case title = "Title"
case year = "Year"
case type = "Type"
case posterUrl = "Poster"
}
}
// root content list view that navigates to the detail view
struct ContentView : View {
var movies: [Movie]
var body: some View {
NavigationView {
List(movies) { movie in
NavigationButton(destination: MovieDetail(movie: movie)) {
MovieRow(movie: movie)
}
}
.navigationBarTitle(Text("Star Wars Movies"))
}
}
}
// detail view that needs to make the asynchronous call
struct MovieDetail : View {
let movie: Movie
#State var imageObject = BoundImageObject()
var body: some View {
HStack(alignment: .top) {
VStack {
Image(uiImage: imageObject.image)
.scaledToFit()
Text(movie.title)
.font(.subheadline)
}
}
}
}
We can achieve this using view modifier.
Create ViewModifier:
struct ViewDidLoadModifier: ViewModifier {
#State private var didLoad = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content.onAppear {
if didLoad == false {
didLoad = true
action?()
}
}
}
}
Create View extension:
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
Use like this:
struct SomeView: View {
var body: some View {
VStack {
Text("HELLO!")
}.onLoad {
print("onLoad")
}
}
}
I hope this is helpful. I found a blogpost that talks about doing stuff onAppear for a navigation view.
Idea would be that you bake your service into a BindableObject and subscribe to those updates in your view.
struct SearchView : View {
#State private var query: String = "Swift"
#EnvironmentObject var repoStore: ReposStore
var body: some View {
NavigationView {
List {
TextField($query, placeholder: Text("type something..."), onCommit: fetch)
ForEach(repoStore.repos) { repo in
RepoRow(repo: repo)
}
}.navigationBarTitle(Text("Search"))
}.onAppear(perform: fetch)
}
private func fetch() {
repoStore.fetch(matching: query)
}
}
import SwiftUI
import Combine
class ReposStore: BindableObject {
var repos: [Repo] = [] {
didSet {
didChange.send(self)
}
}
var didChange = PassthroughSubject<ReposStore, Never>()
let service: GithubService
init(service: GithubService) {
self.service = service
}
func fetch(matching query: String) {
service.search(matching: query) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let repos): self?.repos = repos
case .failure: self?.repos = []
}
}
}
}
}
Credit to: Majid Jabrayilov
Fully updated for Xcode 11.2, Swift 5.0
I think the viewDidLoad() just equal to implement in the body closure.
SwiftUI gives us equivalents to UIKit’s viewDidAppear() and viewDidDisappear() in the form of onAppear() and onDisappear(). You can attach any code to these two events that you want, and SwiftUI will execute them when they occur.
As an example, this creates two views that use onAppear() and onDisappear() to print messages, with a navigation link to move between the two:
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Hello World")
}
}
}.onAppear {
print("ContentView appeared!")
}.onDisappear {
print("ContentView disappeared!")
}
}
}
ref: https://www.hackingwithswift.com/quick-start/swiftui/how-to-respond-to-view-lifecycle-events-onappear-and-ondisappear
I'm using init() instead. I think onApear() is not an alternative to viewDidLoad(). Because onApear is called when your view is being appeared. Since your view can be appear multiple times it conflicts with viewDidLoad which is called once.
Imagine having a TabView. By swiping through pages onApear() is being called multiple times. However viewDidLoad() is called just once.

Resources