Swift Updating Screen in Between Steps of While Loop

Updating UILabel in the middle of a for() loop

You can make the UI update by telling the run loop to run like this:

for (NSInteger i = 0; i < 10; i++) {
[label setText:...];
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantPast]];
}

Updating the UI Using Dispatch_Async in Swift

Three observations, two basic, one a little more advanced:

  1. Your loop will not be able to update the UI in that main thread unless the loop itself is running on another thread. So, you can dispatch it to some background queue. In Swift 3:

    DispatchQueue.global(qos: .utility).async {
    for i in 0 ..< kNumberOfIterations {

    // do something time consuming here

    DispatchQueue.main.async {
    // now update UI on main thread
    self.progressView.setProgress(Float(i) / Float(kNumberOfIterations), animated: true)
    }
    }
    }

    In Swift 2:

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    for i in 0 ..< kNumberOfIterations {

    // do something time consuming here

    dispatch_async(dispatch_get_main_queue()) {
    // now update UI on main thread
    self.progressView.setProgress(Float(i) / Float(kNumberOfIterations), animated: true)
    }
    }
    }
  2. Also note that the progress is a number from 0.0 to 1.0, so you presumably want to divide by the maximum number of iterations for the loop.

  3. If UI updates come more quickly from the background thread than the UI can handle them, the main thread can get backlogged with update requests (making it look much slower than it really is). To address this, one might consider using dispatch source to decouple the "update UI" task from the actual background updating process.

    One can use a DispatchSourceUserDataAdd (in Swift 2, it's a dispatch_source_t of DISPATCH_SOURCE_TYPE_DATA_ADD), post add calls (dispatch_source_merge_data in Swift 2) from the background thread as frequently as desired, and the UI will process them as quickly as it can, but will coalesce them together when it calls data (dispatch_source_get_data in Swift 2) if the background updates come in more quickly than the UI can otherwise process them. This achieves maximum background performance with optimal UI updates, but more importantly, this ensures the UI won't become a bottleneck.

    So, first declare some variable to keep track of the progress:

    var progressCounter: UInt = 0

    And now your loop can create a source, define what to do when the source is updated, and then launch the asynchronous loop which updates the source. In Swift 3 that is:

    progressCounter = 0

    // create dispatch source that will handle events on main queue

    let source = DispatchSource.makeUserDataAddSource(queue: .main)

    // tell it what to do when source events take place

    source.setEventHandler() { [unowned self] in
    self.progressCounter += source.data

    self.progressView.setProgress(Float(self.progressCounter) / Float(kNumberOfIterations), animated: true)
    }

    // start the source

    source.resume()

    // now start loop in the background

    DispatchQueue.global(qos: .utility).async {
    for i in 0 ..< kNumberOfIterations {
    // do something time consuming here

    // now update the dispatch source

    source.add(data: 1)
    }
    }

    In Swift 2:

    progressCounter = 0

    // create dispatch source that will handle events on main queue

    let source = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue());

    // tell it what to do when source events take place

    dispatch_source_set_event_handler(source) { [unowned self] in
    self.progressCounter += dispatch_source_get_data(source)

    self.progressView.setProgress(Float(self.progressCounter) / Float(kNumberOfIterations), animated: true)
    }

    // start the source

    dispatch_resume(source)

    // now start loop in the background

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    for i in 0 ..< kNumberOfIterations {

    // do something time consuming here

    // now update the dispatch source

    dispatch_source_merge_data(source, 1);
    }
    }

How do you setup a loop with an interval/delay?

You are using while in a way it is not meant to be used.

This is how you should use while :

var someCondition = true

while someCondition {

// this will loop as fast as possible untill someConditionIsTrue is no longer true
// inside the while statement you will do stuff x number of times
// then when ready you set someCondition to false
someCondition = false // stop

}

This is how you are using while :

let someConditionThatIsAlwaysTrue = true

while someConditionThatIsAlwaysTrue {

// condition is always true, so inifinite loop...

// this creates a function that is executed 3 seconds after the current looping pass of the while loop.
// while does not wait for it to be finished.
// while just keeps going.
// a fraction of a second later it will create another function that will execute 3 seconds later.
// so after 3 seconds an infite amount of functions will execute with a fraction of a second between them.
// except they won't, since the main thread is still busy with your infinite while loop.
delay(3) {
// stuff
}
}

How to do it the right way :

  • don't ever use while or repeat to "plan" delayed code execution.

  • Split up the problem in smaller problems:


Issue 1 : Creating a loop

A loop is created by have two functions that trigger each other.
I will call them execute and executeAgain.

So execute triggers executeAgain and executeAgain triggers execute and then it starts all over again -> Loop!

Instead of calling execute and executeAgain directly, you also create a start function. This is not needed but it is a good place to setup conditions for your looping function. start will call execute and start the loop.

To stop the loop you create a stop function that changes some condition.
execute and executeAgain will check for this condition and only keep on looping if the check is successful. stop makes this check fail.

var mustLoop : Bool = false

func startLoop() {
mustLoop = true
execute()
}

func execute() {
if mustLoop {
executeAgain()
}
}

func executeAgain() {
if mustLoop {
execute()
}
}

func stop() {
mustLoop = false
}

Issue 2: Delayed Execution

If you need a delay inside a subclass of NSObject the most obvious choice is NSTimer. Most UI classes (like UIButton and UIViewController) are subclasses of NSObject.

NSTimer can also be set to repeat. This would also create a loop that executes every x seconds. But since you actually have 2 alternating actions it makes more sense to adopt the more verbose looping pattern.

An NSTimer executes a function (passed as Selector("nameOfFunction")) after x amount of time.

var timer : NSTimer?

func planSomething() {
timer = NSTimer.scheduledTimerWithTimeInterval(3, target: self, selector: Selector("doSomething"), userInfo: nil, repeats: false)
}

func doSomething() {
// stuff
}

If you need a delay in another class/struct (or you don't like NSTimer) you can use the delay function that matt posted.

It will execute whatever you enter in the closure after x amount of time.

func planSomething() {
delay(3) {
doSomething()
}
}

func doSomething() {
// stuff
}

Combining the two solutions:

By using the loop pattern above you now have distinct functions. Instead of calling them directly to keep the loop going. You insert the delay method of your choice and pass the next function to it.

So NSTimer will have a Selector pointing to execute or executeAgain and with delay you place them in the closure


How to implement it elegantly:

I would subclass UIButton to implement all this. Then you can keep your UIViewController a lot cleaner. Just choose the subclass in IB and connect the IBOutlet as usual.

Sample Image

This subclass has a timer attribute that will replace your delay.
The button action wacked() is also set in the init method.

From your UIViewController you call the start() func of the button. This will start the timer.

The timer will trigger appear() or disappear.

wacked() will stop the timer and make the button hide.

class WackingButton : UIButton {

var timer : NSTimer?

var hiddenTime : NSTimeInterval = 3
var popUpTime : NSTimeInterval = 1

override init(frame: CGRect) {
super.init(frame: frame)
self.addTarget(self, action: "wacked", forControlEvents: UIControlEvents.TouchUpInside)
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.addTarget(self, action: "wacked", forControlEvents: UIControlEvents.TouchUpInside)
}

func start() {
timer = NSTimer.scheduledTimerWithTimeInterval(hiddenTime, target: self, selector: Selector("appear"), userInfo: nil, repeats: false)
}

func appear() {

self.center = randomPosition()

self.hidden = false

timer?.invalidate()
timer = NSTimer.scheduledTimerWithTimeInterval(popUpTime, target: self, selector: Selector("dissappear"), userInfo: nil, repeats: false)
}

func dissappear() {

self.hidden = true

timer?.invalidate()
timer = NSTimer.scheduledTimerWithTimeInterval(hiddenTime, target: self, selector: Selector("appear"), userInfo: nil, repeats: false)
}

func wacked() {
self.hidden = true
timer?.invalidate()
}

func randomPosition() -> CGPoint {

// Find the width and height of the enclosing view
let viewWidth = self.superview?.bounds.width ?? 0 // not really correct, but only fails when there is no superview and then it doesn't matter anyway. Won't crash...
let viewHeight = self.superview?.bounds.height ?? 0

// Compute width and height of the area to contain the button's center
let xwidth = viewWidth - frame.width
let yheight = viewHeight - frame.height

// Generate a random x and y offset
let xoffset = CGFloat(arc4random_uniform(UInt32(xwidth)))
let yoffset = CGFloat(arc4random_uniform(UInt32(yheight)))

// Offset the button's center by the random offsets.
let x = xoffset + frame.width / 2
let y = yoffset + frame.height / 2

return CGPoint(x: x, y: y)
}
}

Your UIViewController :

class ViewController: UIViewController {

@IBOutlet weak var button1: WackingButton!

override func viewDidAppear(animated: Bool) {
button1.start()
}
}

UI is not updating while some function takes time to execution?

UI updates take place on the main thread in iOS and Mac OS. If you have time-consuming code that runs on a background thread, you can send messages to the main thread to update the UI and it works perfectly.

If you are running your time-consuming code on the main thread, UI changes get accumulated, and only get rendered to the screen when your app returns and visits the event loop.

So, if you have a loop that does a bunch of time-consuming tasks and doesn't return until the whole loop is finished, it doesn't work because the UI updates don't take place until you return.

You need to refactor your code to return between iterations. Something like this:

Create an instance variable assetCount:

@interface myClass: UIViewController;
{
NSInteger assetIndex;
}
@end.

Then

-(void) viewDidLoad;
{
assetIndex = 0;
[self processAssets];
}

- (void) processAssets;
{
if (assetIndex >= assets.count)
return;
ALAsset *asset = assets[assetIndex];
weakSelf.HUD.labelText = [NSString stringWithFormat:@"%d/%d",count,assets.coun
weakSelf.HUD.progress=(CGFloat)count/assets.count;
[objDoc setImageProcess:asset];
assetIndex++;
count++;

//The following method call queues up a method call
//for the next time your app visits the event loop. (plus an optional delay)
[self performsSelector: processAssets
withObject: nil
afterDelay: 0];
}

The method above processes 1 asset, then queues a delayed call to itself. Even though the delay value is 0 it still fixes your problem. That's because the performSelector:withObject:afterDelay: method always returns immediately, and queues up the method call for the next pass through the event loop. In the next pass through the event loop, the system does housekeeping like updating the screen, then checks for pending calls like this one. If there is a delay it will start a timer. If not, it will trigger your method call.

You have a variable count in the code you posted that might work as an array index. I added a new instance variable assetIndex to track the current array index. Your code also uses the term weakSelf, suggesting that this code is being executed from a block. It might be that there is a cleaner way to handle this, but you would need to provide more information.

How to refresh everything on screen

Since it's best practice to use the main thread for the UI (like H2CO3 mentioned), I wouldn't recommend using sleepForTimeInterval, as it clogs up the thread doing arguably the most important job (at least for the user). Instead, you could use a timer like this:

- (void) updatePosition {

while(something.center.x < 50) {
something.center = CGPointMake(something.center.x + 1, something.center.y);
}
}

NSTimer *timer = [NSTimer timerWithTimeInterval:0.05 target:self selector:@selector(updatePosition) userInfo:nil repeats:YES];
[timer fire];

Of course you'll have to tweak the code and make some modifications, but that's the gist of what you should do.

Activity Indicator with loop

Try this out:

let activityIndicator = UIActivityIndicatorView.init(activityIndicatorStyle:UIActivityIndicatorViewStyle.WhiteLarge)
self.view.addSubview(activityIndicator)

// Switch To Background Thread
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)) { () -> Void in

// Animate Activity Indicator On Main Thread
dispatch_async(dispatch_get_main_queue(), { () -> Void in
activityIndicator.startAnimating()
})

// Do your table calculation work here

// Stop Animating Activity Indicator On Main Thread
dispatch_async(dispatch_get_main_queue(), { () -> Void in
activityIndicator.stopAnimating()
})
}

How to add a delay to a loop?

It will be more efficient to use an NSTimer.

NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:numberOfSeconds
target:self
selector:@selector(methodToAddImages:)
userInfo:nil
repeats:YES];

This will essentially call methodToAddImages repeatedly with the specified time interval. To stop this method from being called, call [NSTimer invalidate] (bear in mind that an invalidated timer cannot be reused, and you will need to create a new timer object in case you want to repeat this process).

Inside methodToAddImages you should have code to go over the array and add the images.
You can use a counter variable to track the index.

Another option (my recommendation) is to have a mutable copy of this array and add lastObject as a subview and then remove it from the mutable copy of your array.

You can do this by first making a mutableCopy in reversed order as shown:

NSMutableArray* reversedImages = [[[images reverseObjectEnumerator] allObjects] mutableCopy];

Your methodToAddImages looks like:

- (void)methodToAddImages
{
if([reversedImages lastObject] == nil)
{
[timer invalidate];
return;
}

UIImageView *imageView = [[UIImageView alloc] initWithFrame:(CGRectMake(40, 40, 40, 40))];
imageView.image = [reversedImages lastObject];
[self.view addSubview:imageView];
[reversedImages removeObject:[reversedImages lastObject]];
}

I don't know if you're using ARC or Manual Retain Release, but this answer is written assuming ARC (based on the code in your question).

swift call function multiple times inside update

you are calling spawn balls 60 times a second by calling it in your update func.
try just checking if a certain requirement is met to upgrade to a higher spawn rate in your update but keep the calls out of the update func.

private var upgradedToLevel2 = false
private var upgradedToLevel3 = false

//called somewhere probably in a start game func
spawnBalls(duration: 1.0)

override func update(_ currentTime: CFTimeInterval) {

if (self.score > 10 && self.score <= 20) && !upgradedToLevel2 {
//this prevents the if loop from running more than once
upgradedToLevel2 = true
self.removeAction(forKey: "spawn")
spawnBalls(duration: 0.5)
}

if (self.score > 20) && !upgradedToLevel3 {
//this prevents the if loop from running more than once
upgradedToLevel3 = true
spawnBalls(duration: 0.33)
}
}

func spawnBalls(duration: Double) {

let wait = SKAction.wait(forDuration: duration)
let action = SKAction.run { self.createBall() }
let repeater = SKAction.repeatForever(SKAction.sequence([wait, action]))

run(repeater, withKey: "spawn")
}


Related Topics



Leave a reply



Submit