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

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

Related

Modifying user input in Swift

I'm trying to learn Swift and have gone through several tutorials, however, I seem to be going in circles and need some direction on how to solve this problem.
My goal is to create a decrementing timer, given a user-input (in seconds), in this case I've chosen a Stepper to -/+ the value. Then begin decrementing the timer on a button press, in this case "Decrement". The counter is displayed on the label.
This problem is super easy if I hard code the starting value, but what purpose would that serve for a UI Test. So, this was the "challenging" task I was able to think of to help understand how SwiftUI works.
The problem I'm encountering is the variable passed by the user is immutable. I have tried making copies of it or assigning it to other variables to manipulate but seem to be going in circles. A nudge in the right direction or a potential solution would go a long way.
struct ContentView: View {
#State private var timeInput: Int = 0
var timer = Timer()
var timeInputCopy: Int {
timeInput
}
var body: some View {
Stepper("Input", value: $timeInput, in: 0...150)
Button("Decrement", action: decrementFunction)
Label(String(timeInputCopy), image: "")
.labelStyle(TitleOnlyLabelStyle())
}
func decrementFunction() {
timer.invalidate()
timer = Timer.schedulerTimer(timeInterval: 1,
target: self,
selector: #selector(ContentView.timerClass),
userInfo: nil,
repeats: true)
}
func timerClass() {
timeInputCopy -= timeInputCopy
if (timeInputCopy == 0) {
timer.invalidate()
}
}
> Cannot assign to property: 'self' is immutable
> Mark method 'mutating' to make 'self' mutable
Attempting to auto-fix as Xcode recommends does not lead to a productive solution. I feel I am missing a core principle here.
As mentioned in my comments above:
timeInputCopy doesn't have a point -- it's not really a copy, it's just a computed property that returns timeInput
You won't have much luck with that form of Timer in SwiftUI with a selector. Instead, look at the Timer publisher.
Here's one solution:
import Combine
import SwiftUI
class TimerManager : ObservableObject {
#Published var timeRemaining = 0
private var cancellable : AnyCancellable?
func startTimer(initial: Int) {
timeRemaining = initial
cancellable = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { _ in
self.timeRemaining -= 1
if self.timeRemaining == 0 {
self.cancellable?.cancel()
}
}
}
}
struct ContentView: View {
#StateObject private var timerManager = TimerManager()
#State private var stepperValue = 60
var body: some View {
Stepper("Input \(stepperValue)", value: $stepperValue, in: 0...150)
Button("Start") {
timerManager.startTimer(initial: stepperValue)
}
Label("\(timerManager.timeRemaining)", image: "")
.labelStyle(TitleOnlyLabelStyle())
}
}
This could all be done in the View, but using the ObservableObject gives a nice separation of managing the state of the timer vs the state of the UI.

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.

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

SwiftUI - disclosure group expanding behaviour - keep top item static

I'm trying to create a drop down menu using swift UI. I'm actually implementing this into a UIKit project.
The basic functionality I am going for should be that the user clicks on a label with a certain data unit on it and a list expands with other units of measure that can be selected.
Here is my SwiftUI code:
import SwiftUI
#available(iOS 14.0.0, *)
struct DataUnitDropDown: View {
var units = ["ltr", "usg", "impg"]
#State private var selectedDataUnit = 0
#State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 15) {
DisclosureGroup(units[selectedDataUnit], isExpanded: $isExpanded) {
VStack {
ForEach(0 ..< units.count, id: \.self) { index in
Text("\(units[index])")
.font(.title3)
.padding(.all)
.onTapGesture {
self.selectedDataUnit = index
withAnimation {
self.isExpanded.toggle()
}
}
}
}
}
}
}
}
So I have a couple of questions here. Firstly, ideally I wanted to place this into an existing horizontal UIStackView. However, the issue I have is that obviously once the dropdown expands, it increases the height of the stack, which is not what I want. Here is a screen shot of the component (see 'ltr'):
When expanded in the stackView:
So I reluctantly placed it in the main view adding some autolayout constraints:
if #available(iOS 14.0.0, *) {
let controller = UIHostingController(rootView: DataUnitDropDown())
controller.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(controller.view)
controller.view.leadingAnchor.constraint(equalTo: refuelInfoView.actualUpliftVolume.trailingAnchor).isActive = true
controller.view.centerYAnchor.constraint(equalTo: refuelInfoView.actualUpliftVolume.centerYAnchor).isActive = true
}
But now when I expand, the whole menu shifts up:
Clearly not what I am looking for.
So I suppose my question is 2-fold:
1- Is there any way I can incorporate this element into an existing stack view without the stack increasing in height when the dropdown is expanded? (I guess not!)
2- If I have to add this to the main view rather than the stack, how can I stop the entire menu shifting up when it expands?
Update:
I have amended the code as follows:
if #available(iOS 14.0.0, *) {
let controller = UIHostingController(rootView: DataUnitDropDown())
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.clipsToBounds = false
controller.view.heightAnchor.constraint(equalToConstant: 22).isActive = true
controller.view.topAnchor.constraint(equalTo: topAnchor).isActive = true
stackSubviews = [inputField, controller.view]
}
Now, without the topAnchor constraint this works perfectly, but I do need this constraint. But I get an error due to the views. not being in the same hierarchy
I assume instead of
controller.view.centerYAnchor.constraint(equalTo:
refuelInfoView.actualUpliftVolume.centerYAnchor).isActive = true
you need
controller.view.topAnchor.constraint(equalTo:
refuelInfoView.actualUpliftVolume.bottomAnchor).isActive = true

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