MVVM - Efficiently pass fetched data from network requests in parent view to child views using SwiftUI & Combine - mvvm

I am building my first app as I learn SwiftUI and the Combine framework, so I am sorry if this question regarding MVVM is somewhat basic, but I am really stuck right now.
I built a SomeWebService class that returns multiple publishers to consume different resources of a Web API that powers my app:
class SomeWebService {
func resourceA() -> AnyPublisher<ResponseA, SomeWebServiceError> { ... }
func resourceB() -> AnyPublisher<ResponseB, SomeWebServiceError> { ... }
// etc...
}
The responses are potentially large structs that I would like to fetch only once in a ParentView and then pass them in the most efficient way to child views that can use a combination of those resources to display processed data. I just don't know how to efficiently pass those resources to the children and initialise their view models with these resources.
I first created a Fetcher:
class Fetcher: ObservableObject {
#Published private(set) var resourceA: ResponseA? // Potentially large struct
#Published private(set) var resourceB: ResponseB? // Potentially large struct
// etc...
private let webService = SomeWebService()
private var cancellableA: AnyCancellable?
private var cancellableB: AnyCancellable?
// etc...
func fetchA() {
cancellableA = webService.resourceA()
.sink(receiveCompletion: { result in
switch result {
case .failure(let error): print(error)
case .finished: break
}
}, receiveValue: { [weak self] response in
self?.resourceA = response
})
}
func fetchB() { ... }
// etc...
}
Then I wanted to use it in the ParentView to perform all network requests only once onAppear, or whenever the user wants to refresh the app:
struct ParentView: View {
#StateObject private var fetcher = Fetcher()
var body: some View {
VStack {
ChildA() // view model needs a combination of resourceA, resourceB, etc
ChildB() // view model needs another combination of resources
// etc ...
}
.onAppear {
fetcher.fetchA()
fetcher.fetchB()
// etc...
}
}
}
Right now each child view has its own view model where I fetch the combination of resources that that particular child view needs, so I am currently fetching and storing unnecessary copies of those resources.
I would like to use the resources as parameters to the children view models, so that I can handle all the data processing logic inside each of those view models. Is this a bad ideia?
If I can go that route, how should I pass those potentially large responses to the children view models?

Related

How can I remove already rendered unneeded Views instead of rendering needed Views?

I am working on a CustomForEach which would act and work like a normal ForEach in SwiftUI, this CustomForEach has it own early days and it has some issues for use for me, which makes me to learn more about SwiftUI and challenge me to try to solve the issues, one of this issues is finding a way to destroy the unneeded Views instated of rendering all needed Views!
Currently when I update lowerBound the CustomForEach starts rendering for new range which is understandable. But the new range need less Views than before and that is not understandable to rendering them again for already rendered Views.
Goal: I want find a way to stop rendering all needed Views because they are already exist and there is no need to rendering again, and just removing the unneeded Views. And also I do not want start an another expensive calculation inside CustomForEach for finding out if the Views already exist!
struct TextView: View {
let string: String
var body: some View {
print("rendering " + string)
return HStack {
Text(string)
Circle().fill(Color.red).frame(width: 5, height: 5, alignment: .center)
}
}
}
struct CustomForEachView<Content: View>: View {
private let id: Int
let range: ClosedRange<Int>
let content: (Int) -> Content
init(range: ClosedRange<Int>, #ViewBuilder content: #escaping (Int) -> Content) {
self.id = range.lowerBound
self.range = range
self.content = content
}
// The issue is rendering all existed Views when lower Bound get updated, even we do not need to render new View in updating lower Bound!
var body: some View {
content(range.lowerBound)
if let suffixRange = suffix(of: range) {
CustomForEachView(range: suffixRange, content: content)
}
}
private func suffix(of range: ClosedRange<Int>) -> ClosedRange<Int>? {
return (range.count > 1) ? (range.lowerBound + 1)...range.upperBound : nil
}
}
struct ContentView: View {
#State private var lowerBound: Int = -2
#State private var upperBound: Int = 2
var body: some View {
HStack {
CustomForEachView(range: lowerBound...upperBound) { item in
TextView(string: item.description)
}
}
HStack {
Button("add lowerBound") { lowerBound += 1 }
Spacer()
Button("add upperBound") { upperBound += 1 }
}
.padding()
}
}
First of all, one thing important thing to understand is that a SwiftUI.View struct is not a view instance that is rendered on the screen. It's merely a description of the desired view hierarchy. The SwiftUI.View instances are going to be recreated and torn down a lot by the framework anyway.
The SwiftUI framework takes care of the actual rendering. It might use UIViews for this, or it might not. That's an implementation detail you shouldn't need to worry about in most cases.
That said, you might be able to help the framework by adding explicit ids to the views by using the id modifier. That way SwiftUI can use that to keep track of which view is which.
But, I'm not sure if that would actually help. Just an idea.

View don't update in real time when running a cycle

I'm making a card game in SwiftUI and having the following problem: when running a cycle, the view updates only on cycle stop, but don't show any changes when running. UI part of code is:
//on the table
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 0) {
if game.gameStarted {
ForEach ((0..<game.onTheTable.count), id: \.self) {number in
VStack {
Image(game.onTheTable[number].pic)
.resizable()
.modifier(CardStyle())
Text("\(ai.getPower(card: game.onTheTable[number]))")
}
}
}
}
}
It actually shows card images "on the table" when I move an item to the game.onTheTable array. But when I run a while loop like "while true" it behaves as I mentioned above. So I've created a simple code with a delay to be able to se how card images one by one appears on the table but it just doesn't work as expected. There's the code for the cycle:
func test() {
gameStarted = true
while deck.cardsInDeck.count > 0 {
onTheTable.append(deck.cardsInDeck[0])
deck.cardsInDeck.remove(at: 0)
usleep(100000)
}
}
Yes, it appends cards, but visually I see the result just when the whole cycle has finished. Any ideas how to fix that to see the cards being added in real time one by one?
SwiftUI is declarative, so it doesn't mesh well with imperative control flow like while loops or system timers. You don't have control over when layout happens. Instead, you need to modify the underlying state which is driving the view, and those updates must happen on the main thread.
Here's one approach, which starts the timer when the view appears. You could also trigger the timer based on user interaction.
Note that you can attach transitions to views, and those transitions can take advantage of .matchedGeometryEffect... So you could have cards animate from their position on the deck to their place on the table, and that could happen automatically as you move items from one array to another—as long as the deck and table views use the same namespace and a consistent ID for each unique card.
struct GameView: View {
#State var deckCards: [Card] = Card.standardDeck
#State var tableCards: [Card] = []
#State var timer: Timer? = nil
var body: some View {
VStack {
DeckView(cards: deckCards)
TableView(cards: tableCards)
}.onAppear {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
moveCard()
}
}
}
func moveCard() {
DispatchQueue.main.async {
guard deckCards.count > 0 else {
self.timer?.invalidate()
return
}
tableCards.append(deckCards.removeFirst())
}
}
}

UI Test for SwiftUI app does't find element with set accessibilityIdentifier after a timer has passed

The app:
The app has a tag cloud that adds and removes tags as Text views every few seconds to a ZStack using ForEach triggered by an ObservableObject. The ZStack has an accessibilityIdentifier set.
The UI test:
In the UI Test I have set a XCTWaiter first. After a certain period of time has passed I then check if the XCUIElement (ZStack) with the accessibilityIdentifier exists.
After that I query the ZStack XCUIElement for all descendants of type .staticText
I also query the XCUIApplication for its descendants of type .staticText
The following issues:
When the XCTWaiter is set too wait too long. It does not find the ZStack XCUIElement with its identifier anymore.
If the XCTWaiter is set to a low wait time or removed the ZStack XCUIElement will be found. But it will never find its descendants of type .staticText. They do exists though because I can find them as descendant of XCUIApplication itself.
My assumption:
I assume that the ZStack with its identifier can only be found by the tests as long as it does not have descendants. And because it doesn't have any descendants at this moment yet, querying the ZStack XCUIElement for its descendants later also fails because the XCUIElement seems to only represent the ZStack at the time it was captured.
Or maybe I have attached the accessibilityIdentifier for the ZStack at the wrong place or SwiftUI is removing it as soon as there are descendants and I should add identifiers to the descendants only. But that would mean I can only query descendants from XCUIApplication itself and never from another XCUIElement? That would make the .children(matching:) quite useless.
Here is the code for a single view iOS app in SwiftUI with tests enabled.
MyAppUITests.swift
import XCTest
class MyAppUITests: XCTestCase {
func testingTheInitialView() throws {
let app = XCUIApplication()
app.launch()
let exp = expectation(description: "Waiting for tag cloud to be populated.")
_ = XCTWaiter.wait(for: [exp], timeout: 1) // 1. If timeout is set higher
let tagCloud = app.otherElements["TagCloud"]
XCTAssert(tagCloud.exists) // 2. The ZStack with the identifier "TagCloud" does not exist anymore.
let tagsDescendingFromTagCloud = tagCloud.descendants(matching: .staticText)
XCTAssert(tagsDescendingFromTagCloud.firstMatch.waitForExistence(timeout: 2)) // 4. However, it never finds the tags as the descendants of the tagCloud
let tagsDescendingFromApp = app.descendants(matching: .staticText)
XCTAssert(tagsDescendingFromApp.firstMatch.waitForExistence(timeout: 2)) // 3. It does find the created tags here.
}
}
ContentView.swift:
import SwiftUI
struct ContentView: View {
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#ObservedObject var tagModel = TagModel()
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}.animation(Animation.easeInOut(duration: 4.0))
.accessibilityIdentifier("TagCloud")
}
}
class TagModel: ObservableObject {
#Published var tags = [String]()
func addNextTag() {
tags.append(String( Date().timeIntervalSince1970 ))
}
func removeOldestTag() {
tags.remove(at: 0)
}
}
struct TagView: View {
#State private var show: Bool = true
#State private var position: CGPoint = CGPoint(x: Int.random(in: 50..<250), y: Int.random(in: 100..<200))
let label: String
var body: some View {
let text = Text(label)
.position(position)
.opacity(show ? 0.0 : 1.0)
.onAppear {
show.toggle()
}
return text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
From what I can see it looks like you are NOT fulfilling the XCTestExpectation you set up with:
let exp = expectation(description: "Waiting...")
In the past, I have used the XCTestExpecation for asynchronous code to signal completion. So like in this example where I set up the XCTestExpectation and provide a description, then made a network call, and on completion of the network call ran exp.fulfill().
let exp = expectation(description: "Recieved success message after uploading onboarding model")
_ = UserController.upsertUserOnboardingModel().subscribe(onNext: { (response) in
expectation.fulfill()
}, onError: { (error) in
print("\(#function): \(error)")
XCTFail()
})
wait(for: [exp], timeout: 10)
Steps should be: Create Expectation -> Fulfil Expectation within X seconds.
I don't see any exp.fulfill() in your provided code so looks like that is a missing step.
Proposed Solutions:
A. So what you could do is fulfill your exp at some point during the number of
seconds specified in the timeout: x.
B. Or you want just wanting a delay you could use sleep(3)
C. Or if you want to wait for existence pick an element and use .waitForExistence(timeout: Int)
Example: XCTAssert(app.searchFields["Search for cars here"].waitForExistence(timeout: 5))

Observe singleton timer value changes with Publisher in Combine

One of the requirements of my application is the ability to start multiple timers, for reporting purposes.
I've tried to store the timers and seconds passed in an #EnvironmentObject with #Published variables, but every time the object refreshes, any view that observes the #EnvironmentObject refreshes too.
Example
class TimerManager: ObservableObject {
#Published var secondsPassed: [String: Int]
var timers: [String:AnyCancellable]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.timers[itemId] = Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.secondsPassed[itemId]! += 1
})
}
func isTimerValid(itemId: String) -> Bool {
return self.timers[itemId].isTimerValid
}
// other code...
}
So for example, if in any other view I need to know if a particular timer is active by calling a function isTimerValid, I need to include this #EnvironmentObject in that view, and it won't stop refreshing it because the timer changes secondsPassed which is Published, causing lags and useless redrawings.
So one thing I did was to cache the itemId of the active timers somewhere else, in a static struct that I update every time I start or stop a timer.
It seemed a bit hacky, so lately I've been thinking to move all this into a Singleton, like this for example
class SingletonTimerManager {
static let singletonTimerManager = SingletonTimerManager()
var secondsPassed: [String: Int]
var timers: [String:AnyCancellable]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.timers[itemId] = Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.secondsPassed[itemId]! += 1
})
}
// other code...
}
and only let some Views observe the changes to secondsPassed. On the plus side, I can maybe move the timer on the background thread.
I've been struggling how to do this properly.
These are my Views (albeit a very simple extract)
struct ContentView: View {
// set outside the ContentView
var selectedItemId: String
// timerValue: set by a publisher?
var body: some View {
VStack {
ItemView(seconds: Binding.constant(timerValue))
}
}
}
struct ItemView: View {
#Binding var seconds: Int
var body: some View {
Text("\(self.seconds)")
}
}
I need to somehow observe the SingletonChronoManager.secondsPassed[selectedItemId] so the ItemView updates in real time.
By putting the timer publisher results into Environment, you are propagating change notifications to all views within the tree that define that environment object, which I'm sure will cause un-needed redraws and performance issues (and as you've seen).
A better mechanism is strongly limiting the views (or subviews) that need to display the constantly updating time, and pass in a reference to a timer publisher directly to them rather than layering it into the environment. Putting the timer itself into a singleton is one option but not critical to this, and won't effect the cascading redraws you're seeing.
How to use a timer with SwiftUI has a "shoving a timer into the view itself", which may work for what you're trying to do, but slightly better is the video here: https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer
In Paul's example, he's stuffing the timer into the view itself - wouldn't be my choice, but for a simple real-time clock view it's not bad. You could just as easily pass in the timer publisher from an external object - like your singleton for example.
I've ended up using the following solution, combining #heckj suggestion and this one from #Mykel.
What I did was separating the AnyCancellable from the TimerPublishers by saving them in specific dictionaries of SingletonTimerManager.
Then, every time an ItemView is declared, I instantiate an autoconnected #State TimerPublisher. Every Timer instance now runs in the .common RunLoop, with a 0.5 tolerance to better help the perfomance as suggested by Paul here: Triggering events repeatedly using a timer
During the .onAppear() call of the ItemView, if a publisher with the same itemId already exists in SingletonTimerManager, I just assign that publisher to the one of my view.
Then I handle it like in #Mykel solution, with start and stopping both ItemView's publisher and SingletonTimerManager publisher.
The secondsPassed are shown in a text stored inside #State var seconds, which gets updated with a onReceive() attached to the ItemView's publisher.
I know that I'm probably creating too many publishers with this solution and I can't pinpoint exactly what happens when copying a publisher variable into another, but overall perfomance is much better now.
Sample Code:
SingletonTimerManager
class SingletonTimerManager {
static let singletonTimerManager = SingletonTimerManager()
var secondsPassed: [String: Int]
var cancellables: [String:AnyCancellable]
var publishers: [String: TimerPublisher]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.publisher[itemId] = Timer
.publish(every: 1, tolerance: 0.5, on: .main, in: .common)
self.cancellables[itemId] = self.publisher[itemId]!.autoconnect().sink(receiveValue: {_ in self.secondsPassed[itemId] += 1})
}
func isTimerValid(_ itemId: String) -> Bool {
if(self.cancellables[itemId] != nil && self.publishers[itemId] != nil) {
return true
}
return false
}
}
ContentView
struct ContentView: View {
var itemIds: [String]
var body: some View {
VStack {
ForEach(self.itemIds, id: \.self) { itemId in
ItemView(itemId: itemId)
}
}
}
}
struct ItemView: View {
var itemId: String
#State var seconds: Int
#State var timerPublisher = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Button("StartTimer") {
// Call startTimer in SingletonTimerManager....
self.timerPublisher = SingletonTimerManager.publishers[itemId]!
self.timerPublisher.connect()
}
Button("StopTimer") {
self.timerPublisher.connect().cancel()
// Call stopTimer in SingletonTimerManager....
}
Text("\(self.seconds)")
.onAppear {
// function that checks if the timer with this itemId is running
if(SingletonTimerManager.isTimerValid(itemId)) {
self.timerPublisher = SingletonTimerManager.publishers[itemId]!
self.timerPublisher.connect()
}
}.onReceive($timerPublisher) { _ in
self.seconds = SingletonTimerManager.secondsPassed[itemId] ?? 0
}
}
}
}

generic parameter 'FalseContent' could not be inferred

I'm trying to create a 4x4 grid of images, and I'd like it to scale from 1 image up to 4.
This code works when the images provided come from a regular array
var images = ["imageOne", "imageTwo", "imageThree", "imageFour"]
However it does not work if the array comes from an object we are bound to:
#ObjectBinding var images = ImageLoader() //Where our array is in images.images
My initialiser looks like this:
init(imageUrls urls: [URL]){
self.images = ImageLoader(urls)
}
And my ImageLoader class looks like this:
class ImageLoader: BindableObject {
var didChange = PassthroughSubject<ImageLoader, Never>()
var images = [UIImage]() {
didSet{
DispatchQueue.main.async {
self.didChange.send(self)
}
}
}
init(){
}
init(_ urls: [URL]){
for image in urls{
//Download image and append to images array
}
}
}
The problem arises in my View
var body: some View {
return VStack {
if images.images.count == 1{
Image(images.images[0])
.resizable()
} else {
Text("More than one image")
}
}
}
Upon compiling, I get the error generic parameter 'FalseContent' could not be inferred, where FalseContent is part of the SwiftUI buildEither(first:) function.
Again, if images, instead of being a binding to ImageLoader, is a regular array of Strings, it works fine.
I'm not sure what is causing the issue, it seems to be caused by the binding, but I'm not sure how else to do this.
The problem is your Image initialiser, your passing a UIImage, so you should call it like this:
Image(uiImage: images.images[0])
Note that when dealing with views, flow control is a little complicated and error messages can be misleading. By commenting the "else" part of the IF statement of your view, the compiler would have shown you the real reason why it was failing.