SwiftUI: #ObservedObject vs #StateObject child view performance? - swiftui

I have the following view:
struct Menu: View {
let sctions:[TestSection] = [
TestSection(id: 0, name: "One", items: [
ListItem(id: 0, name: "1"),
ListItem(id: 1, name: "2"),
ListItem(id: 2, name: "3"),
ListItem(id: 3, name: "4")
]),
TestSection(id: 1, name: "Two", items: [
ListItem(id: 4, name: "4"),
ListItem(id: 5, name: "5"),
ListItem(id: 6, name: "6"),
ListItem(id: 7, name: "7")
]),
TestSection(id: 2, name: "Three", items: [
ListItem(id: 8, name: "8"),
ListItem(id: 9, name: "9"),
])
]
var body: some View {
NavigationView {
List {
ForEach(sctions) { section in
Section(header: Text(section.name)) {
ForEach(section.items) { item in
TestCell(item: item)
}
}
}
}
.listStyle(.plain)
.navigationBarTitle("Title")
}
}
}
struct TestCell: View {
#ObservedObject private var model:ItemModel
let item:ListItem
init(item:ListItem) {
self.item = item
self.model = ItemModel(itemId:item.id)
}
var body: some View {
Text("item: \(item.name)")
}
}
class ItemModel:ObservableObject {
#Published var someProperty:Int
let itemId:Int
init(itemId:Int) {
self.itemId = itemId
self.someProperty = 0
}
}
I am trying to decide how to handle child views in SwiftUI from a model layer point of view.
One option is #ObservedObject in the child view. The parent created the model and sets it on the child or the parent passes in a property on the child that then allows the child to init the model in its initializer:
init(item:ListItem) {
self.item = item
self.model = ItemModel(itemId:item.id). // <<---- HERE
}
Then looking at this, I wonder if this is less performant than using a #StateObject in the child view that would manage its model's lifecycle.
So then I tried this:
struct TestCell: View {
#StateObject var model = ItemModel() // <<-- error "Missing argument for parameter 'itemId' in call"
let item:ListItem
var body: some View {
Text("item: \(item.name)")
}
}
Which, obviously shows the error:
I am not sure how to initialize ItemModel in TestCell when using the #StateObject approach.
The question I have is:
in this type of scenario, where I want each cell to have its own model (to handle model related logic like making network calls, updating model layer etc...) should I be creating the model as I instantiate the cell within the parent during cell creation, and setting it on the cell (#ObservedObject)?
Or should a cell use #StateObject for its model, and if so, how would I have the model initialize itself properly when it needs parameters, like let itemId:Int for example?

Your first option is not recommended because
init(item:ListItem) {
self.item = item
self.model = ItemModel(itemId:item.id). // <<---- HERE
}
it’s unsafe to create an observed object inside a view
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
You will find many questions in SO that talk about Views being unreliable. You'll also find the ones that tell people to do it this way but you will see issues with it.
If you use #StateObject var model: ItemModel = ItemModel() which is the only exception according to Apple for creating an ObservableObject In a View
And switch
let itemId:Int
in your ItemModel to
var itemId:Int = 0
Then in TestCell add to the outermost View
.onAppear(){
model.itemId = item.id
}
Or depending on your ParentView and its ViewModel you can create the ObservableObjects in the class ViewModel and pass it as a parameter for
#ObservedObject private var model:ItemModel
The important thing is that the workaround takes into consideration that an ObservableObject should not be created in a View with the exception of #StateObject. SwiftUI reserves the right to re-create any View whenever it decides that it is necessary.

Related

In SwiftUI, how do you filter a list on a subview when passing data through a NavigationLink?

I am new to SwiftUI and programming in general. I am trying to pass data and create navigation between different views in my app.
For my data model, I am using MVVM format even though my data is entirely static right now. I have two data models that I am trying to connect via enumeration: CategoryModel and SubCategoryModel (see below).
CategoryModel:
import Foundation
import SwiftUI
enum Category: String, CaseIterable, Identifiable {
var id: String { self.rawValue }
case explicit = "Explicit"
case adventure = "Adventure"
case artists = "Artists"
case holidays = "Holidays"
case movies = "Movies"
case people = "People"
case tasks = "Tasks"
case feelings = "Feelings"
case lifestyle = "Lifesytle"
case party = "Party"
case sports = "Sports"
}
//Root data -> set up data structure
//make Identifiable for ForEach looping in other views
struct CategoryModel: Identifiable {
let id = UUID().uuidString
let category: Category
let categoryTitle: String
let description: String
let isPurchase: Bool
let categoryImage: Image
}
class CategoryViewModel: ObservableObject {
#Published var gameCategories: [CategoryModel] = CategoryModel.all
#Published var filteredPurchase: [CategoryModel] = []
init() {
filterByPurchase()
}
func filterByPurchase() {
filteredPurchase = gameCategories.filter({ $0.isPurchase })
}
}
extension CategoryModel {
static let all = [
CategoryModel(
category: .explicit,
categoryTitle: "Adults Only",
description: "For those spicy, intimate moments.",
isPurchase: true,
categoryImage: Image(uiImage: #imageLiteral(resourceName: "Explicit"))
),
CategoryModel(
category: .adventure,
categoryTitle: "Call of the Wild",
description: "[Insert description here]",
isPurchase: false,
categoryImage: Image(uiImage: #imageLiteral(resourceName: "Adventure"))
),
CategoryModel(
category: .artists,
categoryTitle: "By the Artist",
description: "[Insert description here]",
isPurchase: false,
categoryImage: Image(uiImage: #imageLiteral(resourceName: "Artists"))
),
]
}
SubCategoryModel:
import Foundation
import SwiftUI
//Root data -> set up data structure
//make Identifiable for ForEach looping in other views
struct SubCategoryModel: Identifiable {
let id = UUID().uuidString
let category: Category
let subcategory: String
let subtitle: String
let subdescription: String
let instruction: String
let cardImage: Image
}
class SubCategoryViewModel: ObservableObject {
#Published var gameSubCategories: [SubCategoryModel] = SubCategoryModel.allSubs
#Published var filteredCategory: [SubCategoryModel] = []
#Published var categoryType: Category = .explicit
init() {
filterByCategory()
}
func filterByCategory() {
DispatchQueue.global(qos: .userInteractive).async {
let results = self.gameSubCategories
.lazy
.filter { item in
return item.category == self.categoryType
}
DispatchQueue.main.async {
self.filteredCategory = results.compactMap({ item in
return item
})
}
}
}
}
extension SubCategoryModel {
static let allSubs = [
SubCategoryModel(
category: .explicit,
subcategory: "Significant Other",
subtitle: "Bedroom Eyes",
subdescription: "[Insert sub-description here]",
instruction: "Instructions:\n \n1) Each player pick a song\n2) Be funny, genuine, or a maverick\n3) Enjoy the trip down memory lane",
cardImage: Image(uiImage: #imageLiteral(resourceName: "Explicit"))
),
SubCategoryModel(
category: .explicit,
subcategory: "Dating",
subtitle: "First Date",
subdescription: "[Insert sub-description here]",
instruction: "Instructions:\n \n1) Each player pick a song\n2) Be funny, genuine, or a maverick\n3) Enjoy the trip down memory lane",
cardImage: Image(uiImage: #imageLiteral(resourceName: "Explicit"))
),
SubCategoryModel(
category: .adventure,
subcategory: "Hiking",
subtitle: "Bedroom Eyes",
subdescription: "[Insert sub-description here]",
instruction: "Instructions:\n \n1) Each player pick a song\n2) Be funny, genuine, or a maverick\n3) Enjoy the trip down memory lane",
cardImage: Image(uiImage: #imageLiteral(resourceName: "Adventure"))
),
]
}
My goal is to click on a card from the CategoryView screen and navigate to the SubCategoryView via a navigation link. I want the SubCategoryView to show a filtered list of subcategories based on the category selected on the CategoryView screen.
CategoryView to SubCategoryView GIF
CategoryLoop code snippet:
struct CategoryLoop: View {
let categories: [CategoryModel]
var body: some View {
ZStack {
ScrollView {
VStack {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 20)], spacing: 20) {
ForEach(categories) { item in
NavigationLink(destination: SubCategoryView()
.navigationTitle(item.category.rawValue)) {
CategoryCard(category: item)
}
}
}
Based on the code in my CategoryLoop file, what is the best/easiest way to pass my model data and filter the list on the SubCategoryView? I am having trouble figuring out how to use the enumeration. Is it possible to write a function that would update #Published var categoryType: Category = .explicit (see SubCategoryModel) based on the card clicked in the LazyVGrid? Also, if you have suggestion on how to better organise my data models, please let me know.

Forward/chained properties from service to model using #published, #StaticObject etc. in SwiftUI

Trying to go from UIKit to SwiftUI I keep wondering how to apply a service layer properly and use observables to publish updates from the service layer all the way out through the view model and to the view.
I have a View that has a View model, which derives from ObservableObject and publishes a property, displayed in the view. Using #EnvironmentObject I can easily access the view model from the view.
struct SomeView: View {
#EnvironmentObject var someViewModel: SomeViewModel
var body: some View {
TextField("Write something here", text: $someViewModel.someUpdateableProperty)
}
}
class SomeViewModel: ObservableObject {
#Published var someUpdateableProperty: String = ""
var someService: SomeService
init(someService: SomeService) {
self.someService = someService
}
// HOW DO I UPDATE self.someUpdateableProperty WHEN someService.someProperty CHANGES?
// HOW DO I UPDATE someService.someProperty WHEN self.someUpdateableProperty CHANGES?
}
class SomeService {
var someProperty: String = ""
func fetchSomething() {
// fetching
someProperty = "Something was fetched"
}
func updateSomething(someUpdateableProperty: String) {
someProperty = someUpdateableProperty
}
}
In my head a simple way could be to forward the property i.e. using computed property
class SomeViewModel: ObservableObject {
#Published var someUpdateableProperty: String {
get {
return someService.someProperty
}
set {
someService.someProperty = value
}
}
var someService: SomeService
init(someService: SomeService) {
self.someService = someService
}
}
Another approach could be if it was possible to set the two published properties equal to each other:
class SomeService: ObservableObject {
#Published var someProperty: String = ""
func fetchSomething() {
// fetching
someProperty = "Something was fetched"
}
}
class SomeViewModel: ObservableObject {
#Published var someUpdateableProperty: String = ""
var someService: SomeService
init(someService: SomeService) {
self.someService = someService
someUpdateableProperty = someService.someProperty
}
}
What is the correct way to forward a publishable property directly through a view model?
In UIKit I would use delegates or closures to call from the service back to the view model and update the published property, but is that doable with the new observables in SwiftUI?
*I know keeping the state in the service is not a good practice, but for the example let's use it.
There are, of course, lots of ways to do this, so I can't speculate on the "correct" way. But one way to do this is to remember that ObservableObject only does one thing - it has an objectWillChange publisher, and that publisher sends automatically when any of the #Published properties changes.
In SwiftUI, that is taken advantage of by the #ObservedObject, #StateObject and #EnvironmentObject property wrappers, which schedule a re-rendering of the view whenever that publisher sends.
However, ObservableObject is actually defined within Combine, and you can build your own responses to the publisher.
If you make SomeService an ObservableObject as in your last code example, then you can observe for changes in SomeViewModel:
private var cancellables = Set<AnyCancellable>()
init(someService: SomeService) {
self.someService = someService
someService.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
Now, any published change to the service will cascade through to observers of the view model.
It's important to note that this publisher is will change, not did change, so if you are surfacing any properties from the service in your view model, you should make them calculated variables. SwiftUI coalesces all of the will change messages, then schedules a single view update which takes place on the next run loop, by which point the new values will be in place. If you extract values from within the sink closure, they will still represent the old data.

Mocking view model in swiftui with combine

Is there a way to mock the viewmodels of an application that uses SwiftUI and Combine? I always find articles about mocking services used by the viewmodel but never mocking a viewmodel itself.
I tried to create protocols for each viewmodel. Problem: the #Published wrapper cannot be used in protocols. It seems like there are no solutions...
Thanks for your help
Using a protocol type as #ObservableObject or #StateObject would not work. Inheritance might be a solution (like Jake suggested), or you could go with a generic solution.
protocol ContentViewModel: ObservableObject {
var message: String { get set }
}
Your view model would be simple.
final class MyViewModel: ContentViewModel {
#Published var message: String
init(_ message: String = "MyViewModel") {
self.message = message
}
}
On the other hand, your views would be more complex using a constrained generic.
struct ContentView<Model>: View where Model: ContentViewModel {
#ObservedObject
var viewModel: Model
var body: some View {
VStack {
Text(viewModel.message)
Button("Change message") {
viewModel.message = "🥳"
}
}
}
}
The disadvantage is that you have to define the generic concrete type when using the view --- inheritance could avoid that.
// your mock implementation for testing
final class MockViewModel: ContentViewModel {
#Published var message: String = "Mock View Model"
}
let sut = ContentView<MockViewModel>(viewModel: MockViewModel())
If the view model is only a model, you should probably not mock that at all, instead you would mock the thing that modifies the view model. If your view model actually owns the functions and things that updates itself, then you would want to mock it directly. In which case you can use protocols to mock the view model. It would look a little like this.
protocol ViewModel {
var title: String { get set }
}
class MyViewModel: ViewModel {
#Published var title: String = "Page title"
}
class MockViewModel: ViewModel {
#Published var title: String = "MockPage title"
}
However, this is probably an instance where I would prefer inheriting a class instead of adhering to a protocol. Then I would override the functionality of the class for the mock.
open class ViewModel {
#Published var title: String
open fun getPageTitle() {
title = "This is the page title"
}
}
class MockViewModel: ViewModel {
override fun getPageTitle() {
title = "some other page title"
}
}
Either way would work really. The inheritance way is just less verbose in your test suite if your View Model has functionality too.

error Generic parameter 'ID' could not be inferred with DatePicker

I have a couple of DatePickers in a view with the following code. Each DatePicker presents a digit, so "3" or "5". So for "3456" I have 4 DatePickers that can be changed individually.
struct DigitPicker: View {
var digitName: String
#Binding var digit: Int
var body: some View {
VStack {
Picker(selection: $digit, label: Text(digitName)) {
ForEach(0 ... 9) {
Text("\($0)").tag($0)
}
}.frame(width: 60, height: 110).clipped()
}
}
}
I get an error "Generic parameter 'ID' could not be inferred". So I guess the reason is that $digit must conform to Identifiable(). But how do I do that???
The compiler is resolving the ForEach using this extension:
extension ForEach where Content : View {
/// Creates an instance that uniquely identifies views across updates based
/// on the `id` key path to a property on an underlying data element.
///
/// It's important that the ID of a data element does not change unless the
/// data element is considered to have been replaced with a new data
/// element with a new identity. If the ID of a data element changes, then
/// the content view generated from that data element will lose any current
/// state and animations.
public init(_ data: Data, id: KeyPath<Data.Element, ID>, content: #escaping (Data.Element) -> Content)
}
And, as you can see, it can't infer the second argument of the init method.
You can make the second argument explicit to make the compiler happy.
ForEach(0 ... 9, id: \.self) { // identified by self
Text("\($0)").tag($0)
}

in Famo.us, how do you pipe events to parent view from inside parent view

I have a custom view that contains a surface. I am needing to pipe the surface events to the parent view. I can do this easily from outside the view, but how do I do this from inside the view? Here is my custom view with the code that does NOT work:
define([
"famous/core/view",
"famous/core/Surface",
"famous/modifiers/StateModifier"
], function(View, Surface, StateModifier){
function _createContainer() {
var self = this;
var container = new Surface({
classes: ['blue-bg'],
content: 'HERE IS A LOVELY BIT OF CONTENT FOR MY SURFACE'
});
// THIS DOESN'T WORK, BUT ILLUSTRATES WHAT I'M NEEDING TO DO:
container.pipe(self);
self.containerNode.add(container);
self.form = container;
}
function FormView(){
var self = this;
View.apply(self, arguments);
var containerMod = new StateModifier({
size: self.options.size
});
self.containerNode = self.add(containerMod);
_createContainer.call(self);
}
FormView.prototype = Object.create(View.prototype);
FormView.prototype.constructor = FormView;
FormView.DEFAULT_OPTIONS = {
size: [300, 800]
};
return FormView;
});
Here is example code that does work, but that I want to do from inside the view:
var myView = new View();
mainContext.add(myView);
var surface = new Surface({
size: [100, 100],
content: 'click me',
properties: {
color: 'white',
textAlign: 'center',
backgroundColor: '#FA5C4F'
}
});
myView.add(surface);
surface.pipe(myView);
Inside your custom view FormView you need to pipe to the view's event handler. This will allow the view's events to be seen by a Scrollview when they are added to surfaces.
Change
container.pipe(self);
to
container.pipe(self._eventOutput);