Swift didSet called but not updating UILabel - iOS Property observer - ios

Any idea why my label.text is only updating when the count finishes?
didSet is called. But the label.text = String(counter) appears to do nothing.
Swift 5
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var label: UILabel!
var counter:Int = 0 {
didSet {
print("old value \(oldValue) and new value: \(counter)")
label.text = String(counter)
sleep(1). // just added to show the label.text is not updating
}
}
#IBAction func start_btn(_ sender: Any) {
for _ in 1...3 {
counter += 1
}
}
}
didSet code is called from the Main Thread. It is all wired correctly with Storyboards ( not SwiftUI).
You can see the didSet code is called.
old value 0 and new value: 1. Main thread: true
old value 1 and new value: 2. Main thread: true
old value 2 and new value: 3. Main thread: true

It looks like you're trying to make some kind of counter which starts at 0 and stops at 3. If that is the case you should not call sleep (which blocks the main thread).
edit: apparently the sleep call was added for demonstration purposes?
In any case the reason why your label seems like it is only updating when the count finishes is because the for loop runs too quickly for the UI to update on each counter increment.
Rather use Timer:
counter = 0
let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
self.counter += 1
if self.counter >= 3 {
timer.invalidate()
}
}
This is based on my rough understanding on what you're aiming to achieve.
You could also DispatchQueue.main.asyncAfter:
func countUp() {
guard counter < 3 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.counter += 1
fire()
}
}
For short time intervals, the difference between the two approaches is going to be pretty insignificant. For really accurate time counting, one shouldn't rely on either though, but rather use Date with a Timer that fires say every tenth of a second, and updates counter by rounding to the nearest second (for example).

You can achieve it like following
#IBAction func start_btn(_ sender: Any) {
updateCounter()
}
func updateCounter() {
if counter == 3 {
return
} else {
counter += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
self.updateCounter()
})
}
}

Never, ever call sleep in an iOS app. That will block the main thread, which means your app will be frozen for a whole second with sleep(1).
This means that the main thread will be blocked while the loop in start_btn finishes and hence the UI can only be updated after the loop has already finished.
If you want to make the text change every second, modify the button action to
#IBAction func start_btn(_ sender: Any) {
for i in 1...3 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i), execute: {
self.counter += 1
})
}
}
and remove sleep(1) from didSet.

Related

UITextView does not update during loop

so I have tried 2 different ways to display the timer count down on the screen.
the code will print to the console but not to the UITextView (in both loop cases) in the repeat the UITextView ends with a 0 and that is the only thing it displays other than original txt "time count".... in the case where the commented loop is implemented the UITextView only displays the 1 (end of count down)... why is it printing to the console though these commands are in the same brackets as UITextView and they repeat
the image is after running code and clicking Soft (this is spin off of app brewery egg timer)
//
// ViewController.swift
// EggTimer
//
// Created by Angela Yu on 08/07/2019.
// Copyright © 2019 The App Brewery. All rights reserved.
//
import UIKit
let softTime = 5
let medTime = 7
let hardTime = 12
class ViewController: UIViewController {
#IBOutlet weak var timerTextView: UITextView!
#IBAction func eggHardnessButton(_ sender: UIButton) {
let hardness = sender.currentTitle
func timeCountDownSoft() {
var time = 3
repeat {
time = time - 1 //repeats
timerTextView.selectAll("") // does not repeat
timerTextView.insertText("") // does not repeat
let timeWord = String(time) // repeats
timerTextView.insertText(timeWord)//does not repeat
print(time) //repeats
sleep(1) //repeats
} while time >= 1
/*for timer in stride(from: 2, to: 0 , by: -1){
let time = String(timer)
timerTextView.selectAll("")
timerTextView.insertText("")
timerTextView.insertText(time)
print(time)
sleep(1)
}*/
}
func timeCountDownMed() {
for timer in stride(from: 420, to: 0 , by: -1) {
print(timer)
sleep(1)
}
}
func timeCountDownHard() {
for timer in stride(from: 720, to: 0 , by: -1) {
print(timer)
sleep(1)
}
}
if hardness == "Soft" {
print(softTime) // does not repeat
timeCountDownSoft() // does not repeat
} else if hardness == "Medium" {
print(medTime)
timeCountDownMed()
} else {
print(hardTime)
timeCountDownHard()
}
}
}
You never (well, almost never) want to use sleep().
The reason your text is not updating is because you are running closed-loops that never allow UIKit to update the view.
What you want to do instead is to use a repeating Timer with a one-second interval. Each time the timer fires, decrement your counter and update the UI. When the counter reaches Zero, stop the timer.
Here's a simple example:
import UIKit
let softTime = 5
let medTime = 7
let hardTime = 12
class ViewController: UIViewController {
#IBOutlet weak var timerTextView: UITextView!
var theTimer: Timer?
#IBAction func eggHardnessButton(_ sender: UIButton) {
let hardness = sender.currentTitle
var numSeconds = 0
// set number of seconds based on button title
switch hardness {
case "Soft":
numSeconds = softTime * 60
case "Medium":
numSeconds = medTime * 60
case "Hard":
numSeconds = hardTime * 60
default:
// some other button called this func, so just return
return()
}
// stop current timer if it's running
if let t = theTimer {
if t.isValid {
t.invalidate()
}
}
// update text field with selected time
let timeWord = String(numSeconds)
self.timerTextView.text = timeWord
// start a timer with 1-second interval
theTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
numSeconds -= 1
let timeWord = String(numSeconds)
self.timerTextView.text = timeWord
// if we've reached Zero seconds remaining
if numSeconds == 0 {
// stop the timer
timer.invalidate()
}
}
}
}
Several points:
When your app changes the UI, those changes do not actually get
rendered to the screen until your code returns and visits the app's
event loop. Thus, with your repeat loop, nothing will show up on in
the UI until the loop completes
Do not use sleep() on the main thread. That blocks everything, and
your app comes to a screeching halt.
If you want to update the UI once a second, set up a repeating timer
that fires once a second and do your UI updates in the timer code.

Wait for Swift timers to finish

So I'm doing a simple game on swift 5 and I basically have a 3..2..1.. go timer to start the game and then a 3..2..1..stop timer to stop the game. And finally a function that displays the score. I need a way for each function call to wait for the timer to be done before the next one begins, any suggestions? Here's my code so far. (Also if you have any other suggestions on the app let me know as well, the end goal is to register how many taps of a button you can do in 3 seconds)
var seconds = 3 //Starting seconds
var countDownTimer = Timer()
var gameTimer = Timer()
var numberOfTaps = 0
override func viewDidLoad() {
super.viewDidLoad()
self.startCountdown(seconds: seconds)
self.gameCountdown(seconds: seconds)
self.displayFinalScore()
}
func startCountdown(seconds: Int) {
countDownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
self?.seconds -= 1
if self?.seconds == 0 {
self?.countdownLabel.text = "Go!"
timer.invalidate()
} else if let seconds = self?.seconds {
self?.countdownLabel.text = "\(seconds)"
}
}
}
func gameCountdown(seconds: Int) {
gameTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
self?.seconds -= 1
if self?.seconds == 0 {
self?.countdownLabel.text = "Stop!"
timer.invalidate()
} else if let seconds = self?.seconds {
self?.countdownLabel.text = "\(seconds)"
}
}
}
deinit {
// ViewController going away. Kill the timer.
countDownTimer.invalidate()
}
#IBOutlet weak var countdownLabel: UILabel!
#IBAction func tapMeButtonPressed(_ sender: UIButton) {
if gameTimer.isValid {
numberOfTaps += 1
}
}
func displayFinalScore() {
if !gameTimer.isValid && !countDownTimer.isValid {
countdownLabel.text = "\(numberOfTaps)"
}
}
You should think about the states your game could be in. It could be -
setup - Establish the game
starting - The first three seconds
running - After the first three seconds but before the end
ending - In the final three seconds
ended - Time is up.
Each time your timer ticks you need to consider what action do you need to take and what state do you need to move to. You haven't said how long you want the game to last, but let's say it is 30 seconds.
When a new game is started, you are in the setup state; The button is disabled (ie. it doesn't react to taps) and you set the score to 0. You move to the starting state.
In the starting you show the countdown. After three seconds you enable the button and move into the running state.
Once you reach 27 seconds, you move into the ending state and show the end count down
Finally time is up and you move into the ended state, disable the button and show the score.
You could code it something like this
enum GameState {
case setup
case starting
case running
case ending
case ended
}
class ViewController: UIViewController {
#IBOutlet weak var startButton: UIButton!
#IBOutlet weak var tapButton: UIButton!
#IBOutlet weak var countdownLabel: UILabel!
var gameState = GameState.ended
var gameTimer:Timer?
var numberOfTaps = 0
var gameStartTime = Date.distantPast
let GAMEDURATION: TimeInterval = 30
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
#IBAction func startButtonTapped(_ sender: UIButton) {
self.startGame()
}
#IBAction func tapMeButtonPressed(_ sender: UIButton) {
self.numberOfTaps += 1
}
func startGame() {
self.gameState = .setup
self.gameTimer = Timer.scheduledTimer(withTimeInterval:0.1, repeats: true) { timer in
let elapsedTime = -self.gameStartTime.timeIntervalSinceNow
let timeRemaining = self.GAMEDURATION-elapsedTime
switch self.gameState {
case .setup:
self.gameStartTime = Date()
self.tapButton.isEnabled = false
self.startButton.isEnabled = false
self.numberOfTaps = 0
self.gameState = .starting
case .starting:
if elapsedTime > 2.5 {
self.gameState = .running
self.tapButton.isEnabled = true
self.countdownLabel.text = "Go!"
} else {
let countdown = Int(3-round(elapsedTime))
self.countdownLabel.text = "\(countdown)"
}
case .running:
if timeRemaining < 4 {
self.gameState = .ending
}
case .ending:
let countdown = Int(timeRemaining)
self.countdownLabel.text = "\(countdown)"
if timeRemaining < 1 {
self.countdownLabel.text = "Stop"
self.gameState = .ended
self.tapButton.isEnabled = false
}
case .ended:
if timeRemaining <= 0 {
self.countdownLabel.text = "You tapped the button \(self.numberOfTaps) times"
self.startButton.isEnabled = true
self.gameTimer?.invalidate()
self.gameTimer = nil
}
}
}
}
}
The approach shouldn't be that the function calls wait for the timer to get finished, rather the timers should call the functions when they finish.
So, you need to move below function calls out of viewDidLoad and put them inside the Timer blocks.
self.gameCountdown(seconds: seconds)
self.displayFinalScore()
I.e. the function call self.gameCountdown(seconds: seconds) will go inside the timer block started in startCountdown. In that, when you are invalidating the timer when the seconds become 0, you call gameCountdown.
Similarly, in the timer started in gameCountdown, you call the self.displayFinalScore when the seconds become 0.
Few other suggestions. You should avoid checking properties in tapMeButtonPressed.
You should rather disable and enable the tap me button instead. I.e. enable it when you start the gameCountdown and disable it when it ends.
Similarly, you shouldn't need to check the state of the timers in displayFinalScore. It should just do one thing i.e. display the final score.
Will save you a lot of headaches later :). My 2 cents.

Add a delay to a for loop in swift

I have a coding 'issue'.
I have a label, which text I want to change dynamically every 2 seconds.
I've done the following:
// WELCOME STRING ARRAY
let welcomeContainer:[String] = ["Welcome","Benvenuti","Bienvenue","Willkommen","üdvözlet","Dobrodošli","добро пожаловать","Witajcie","Bienvenido","Ласкаво просимо","Vitajte","欢迎你来"]
and then, rather than using a timerwithinterval (which seemed to be too much for this simple task), I tried with the delay method in my function inside for loop:
func welcomeLabelChange() {
for i in 0..<welcomeContainer.count {
welcomeLabel.text = welcomeContainer[i]
delay(delay: 2.0, closure: {})
}
Unfortunately it's entirely skipping the delay... the for loop is executed instantly and just the last text in the array is displayed.
What am I doing wrong?
I found this OBJ-C answer, but it's suggesting an (old) NSTimer implementation.
You can also use this function to delay something
//MARK: Delay func
func delay(_ delay:Double, closure:#escaping ()->()) {
DispatchQueue.main.asyncAfter(
deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: closure)
}
and usage is :
delay(2) //Here you put time you want to delay
{
//your delayed code
}
Hope it will help you.
define those variables
var i = 0
let timer : Timer?
Place this timer in your view did load or wherever you want to start the label change
timer = Timer.scheduledTimer(timeInterval: 2.0, target: self, selector:#selector(YourViewController.changeText), userInfo: nil, repeats: true)
and implement this method:
func changeText(){
if i>=welcomeContainer.count {
i = 0
}
welcomeLabel.text = welcomeContainer[i]
i += 1
}
when you want to stop it or change the view controller dont forget to call
timer.invalidate()
You can add sleep function
for i in 0..<welcomeContainer.count {
welcomeLabel.text = welcomeContainer[i]
sleep(2) // or sleep(UInt32(0.5)) if you need Double
}
If you want to keep it all inline you can do this:
var loop: ((Int) -> Void)!
loop = { [weak self] count in
guard count > 0 else { return }
//Do your stuff
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
loop(count - 1)
}
}
loop(10) //However many loops you want
With Timer, you should be careful to call invalidate of the Timer in viewDidDisappear or else you may not release the view controller.
Alternatively, you can use a GCD dispatch timer, in which you completely eliminate the strong reference cycle by using [weak self] pattern:
#IBOutlet weak var welcomeLabel: UILabel!
var timer: DispatchSourceTimer!
override func viewDidLoad() {
super.viewDidLoad()
let welcomeStrings = ["Welcome", "Benvenuti", "Bienvenue", "Willkommen", "üdvözlet", "Dobrodošli", "добро пожаловать", "Witajcie", "Bienvenido", "Ласкаво просимо", "Vitajte", "欢迎你来"]
var index = welcomeStrings.startIndex
timer = DispatchSource.makeTimerSource(queue: .main)
timer.scheduleRepeating(deadline: .now(), interval: .seconds(2))
timer.setEventHandler { [weak self] in
self?.welcomeLabel.text = welcomeStrings[index]
index = index.advanced(by: 1)
if index == welcomeStrings.endIndex {
index = welcomeStrings.startIndex // if you really want to stop the timer and not have this repeat, call self?.timer.cancel()
}
}
timer.resume()
}
Marked answer doesn't delay loop iterations and you still get just the last value in the label.text.
You can solve it like this:
func showWelcome(_ iteration: Int = 0) {
let i = iteration>=self.welcomeContainer.count ? 0 : iteration
let message = self.welcomeContainer[i]
self.delay(2){
self.welcomeLabel.text = message
return self.showWelcome(i + 1)
}
}
Usage:
showWelcome()

If i add this function on the code, NSTimer stops, why?

There are 2 Arrays. First one contains Strings that i want to show on UILabel. Second one contains their waiting durations on UILabel.
let items = ["stone","spoon","brush","ball","car"]
let durations = [3,4,1,3,2]
And two variables for specifying which one is on the go.
var currentItem = 0
var currentDuration = 0
This one is the timer system:
var timer = NSTimer()
var seconds = 0
func addSeconds () {seconds++}
func setup () {
timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "addSeconds", userInfo: nil, repeats: true)
}
Finally, that's the loop. Answer of: Which Array item stays how many seconds on the UILabel question.
func flow () {
while seconds <= durations[currentDuration] {
myScreen.text = items[currentItem]
if seconds == durations[currentDuration]{
seconds == 0
currentItem++
currentDuration++
}
}
Label and Button:
#IBOutlet weak var myScreen: UILabel!
#IBAction func startButton(sender: UIButton) {
setup()
}
}
If i change this:
func addSeconds () {seconds++}
To that:
func addSeconds () {seconds++ ; flow () }
For setting the loop, nothing happens. Even NSTimer, it stops at 1st second.
Because your flow method has a while loop that never exits and blocks the main thread, so the timer can never fire.
Don't use a while loop. Us the method triggered by the timer to update the UI.
So:
func addSeconds () {
seconds++
myScreen.text = items[currentItem]
if seconds == durations[currentDuration] {
seconds == 0
currentItem++
currentDuration++
}
}

How to throttle search (based on typing speed) in iOS UISearchBar?

I have a UISearchBar part of a UISearchDisplayController that is used to display search results from both local CoreData and remote API.
What I want to achieve is the "delaying" of the search on the remote API. Currently, for each character typed by the user, a request is sent. But if the user types particularly fast, it does not make sense to send many requests: it would help to wait until he has stopped typing.
Is there a way to achieve that?
Reading the documentation suggests to wait until the users explicitly taps on search, but I don't find it ideal in my case.
Performance issues. If search operations can be carried out very
rapidly, it is possible to update the search results as the user is
typing by implementing the searchBar:textDidChange: method on the
delegate object. However, if a search operation takes more time, you
should wait until the user taps the Search button before beginning the
search in the searchBarSearchButtonClicked: method. Always perform
search operations a background thread to avoid blocking the main
thread. This keeps your app responsive to the user while the search is
running and provides a better user experience.
Sending many requests to the API is not a problem of local performance but only of avoiding too high request rate on the remote server.
Thanks
Try this magic:
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText{
// to limit network activity, reload half a second after last key press.
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:#selector(reload) object:nil];
[self performSelector:#selector(reload) withObject:nil afterDelay:0.5];
}
Swift version:
func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
// to limit network activity, reload half a second after last key press.
NSObject.cancelPreviousPerformRequestsWithTarget(self, selector: "reload", object: nil)
self.performSelector("reload", withObject: nil, afterDelay: 0.5)
}
Note this example calls a method called reload but you can make it call whatever method you like!
For people who need this in Swift 4 onwards:
Keep it simple with a DispatchWorkItem like here.
or use the old Obj-C way:
func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
// to limit network activity, reload half a second after last key press.
NSObject.cancelPreviousPerformRequestsWithTarget(self, selector: "reload", object: nil)
self.performSelector("reload", withObject: nil, afterDelay: 0.5)
}
EDIT: SWIFT 3 Version
func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
// to limit network activity, reload half a second after last key press.
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.reload), object: nil)
self.perform(#selector(self.reload), with: nil, afterDelay: 0.5)
}
#objc func reload() {
print("Doing things")
}
Improved Swift 4+:
Assuming that you are already conforming to UISearchBarDelegate, this is an improved Swift 4 version of VivienG's answer:
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.reload(_:)), object: searchBar)
perform(#selector(self.reload(_:)), with: searchBar, afterDelay: 0.75)
}
#objc func reload(_ searchBar: UISearchBar) {
guard let query = searchBar.text, query.trimmingCharacters(in: .whitespaces) != "" else {
print("nothing to search")
return
}
print(query)
}
The purpose of implementing cancelPreviousPerformRequests(withTarget:) is to prevent the continuous calling to the reload() for each change to the search bar (without adding it, if you typed "abc", reload() will be called three times based on the number of the added characters).
The improvement is: in reload() method has the sender parameter which is the search bar; Thus accessing its text -or any of its method/properties- would be accessible with declaring it as a global property in the class.
Thanks to this link, I found a very quick and clean approach. Compared to Nirmit's answer it lacks the "loading indicator", however it wins in terms of number of lines of code and does not require additional controls. I first added the dispatch_cancelable_block.h file to my project (from this repo), then defined the following class variable: __block dispatch_cancelable_block_t searchBlock;.
My search code now looks like this:
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
if (searchBlock != nil) {
//We cancel the currently scheduled block
cancel_block(searchBlock);
}
searchBlock = dispatch_after_delay(searchBlockDelay, ^{
//We "enqueue" this block with a certain delay. It will be canceled if the user types faster than the delay, otherwise it will be executed after the specified delay
[self loadPlacesAutocompleteForInput:searchText];
});
}
Notes:
The loadPlacesAutocompleteForInput is part of the LPGoogleFunctions library
searchBlockDelay is defined as follows outside of the #implementation:
static CGFloat searchBlockDelay = 0.2;
A quick hack would be like so:
- (void)textViewDidChange:(UITextView *)textView
{
static NSTimer *timer;
[timer invalidate];
timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:#selector(requestNewDataFromServer) userInfo:nil repeats:NO];
}
Every time the text view changes, the timer is invalidated, causing it not to fire. A new timer is created and set to fire after 1 second. The search is only updated after the user stops typing for 1 second.
Swift 4 solution, plus some general comments:
These are all reasonable approaches, but if you want exemplary autosearch behavior, you really need two separate timers or dispatches.
The ideal behavior is that 1) autosearch is triggered periodically, but 2) not too frequently (because of server load, cellular bandwidth, and the potential to cause UI stutters), and 3) it triggers rapidly as soon as there is a pause in the user's typing.
You can achieve this behavior with one longer-term timer that triggers as soon as editing begins (I suggest 2 seconds) and is allowed to run regardless of later activity, plus one short-term timer (~0.75 seconds) that is reset on every change. The expiration of either timer triggers autosearch and resets both timers.
The net effect is that continuous typing yields autosearches every long-period seconds, but a pause is guaranteed to trigger an autosearch within short-period seconds.
You can implement this behavior very simply with the AutosearchTimer class below. Here's how to use it:
// The closure specifies how to actually do the autosearch
lazy var timer = AutosearchTimer { [weak self] in self?.performSearch() }
// Just call activate() after all user activity
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
timer.activate()
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
performSearch()
}
func performSearch() {
timer.cancel()
// Actual search procedure goes here...
}
The AutosearchTimer handles its own cleanup when freed, so there's no need to worry about that in your own code. But don't give the timer a strong reference to self or you'll create a reference cycle.
The implementation below uses timers, but you can recast it in terms of dispatch operations if you prefer.
// Manage two timers to implement a standard autosearch in the background.
// Firing happens after the short interval if there are no further activations.
// If there is an ongoing stream of activations, firing happens at least
// every long interval.
class AutosearchTimer {
let shortInterval: TimeInterval
let longInterval: TimeInterval
let callback: () -> Void
var shortTimer: Timer?
var longTimer: Timer?
enum Const {
// Auto-search at least this frequently while typing
static let longAutosearchDelay: TimeInterval = 2.0
// Trigger automatically after a pause of this length
static let shortAutosearchDelay: TimeInterval = 0.75
}
init(short: TimeInterval = Const.shortAutosearchDelay,
long: TimeInterval = Const.longAutosearchDelay,
callback: #escaping () -> Void)
{
shortInterval = short
longInterval = long
self.callback = callback
}
func activate() {
shortTimer?.invalidate()
shortTimer = Timer.scheduledTimer(withTimeInterval: shortInterval, repeats: false)
{ [weak self] _ in self?.fire() }
if longTimer == nil {
longTimer = Timer.scheduledTimer(withTimeInterval: longInterval, repeats: false)
{ [weak self] _ in self?.fire() }
}
}
func cancel() {
shortTimer?.invalidate()
longTimer?.invalidate()
shortTimer = nil; longTimer = nil
}
private func fire() {
cancel()
callback()
}
}
Swift 2.0 version of the NSTimer solution:
private var searchTimer: NSTimer?
func doMyFilter() {
//perform filter here
}
func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
if let searchTimer = searchTimer {
searchTimer.invalidate()
}
searchTimer = NSTimer.scheduledTimerWithTimeInterval(0.5, target: self, selector: #selector(MySearchViewController.doMyFilter), userInfo: nil, repeats: false)
}
Please see the following code which i've found on cocoa controls. They are sending request asynchronously to fetch the data. May be they are getting data from local but you can try it with the remote API. Send async request on remote API in background thread. Follow below link:
https://www.cocoacontrols.com/controls/jcautocompletingsearch
We can use dispatch_source
+ (void)runBlock:(void (^)())block withIdentifier:(NSString *)identifier throttle:(CFTimeInterval)bufferTime {
if (block == NULL || identifier == nil) {
NSAssert(NO, #"Block or identifier must not be nil");
}
dispatch_source_t source = self.mappingsDictionary[identifier];
if (source != nil) {
dispatch_source_cancel(source);
}
source = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
dispatch_source_set_timer(source, dispatch_time(DISPATCH_TIME_NOW, bufferTime * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, 0);
dispatch_source_set_event_handler(source, ^{
block();
dispatch_source_cancel(source);
[self.mappingsDictionary removeObjectForKey:identifier];
});
dispatch_resume(source);
self.mappingsDictionary[identifier] = source;
}
More on Throttling a block execution using GCD
If you're using ReactiveCocoa, consider throttle method on RACSignal
Here is ThrottleHandler in Swift in you're interested
You can use DispatchWorkItem with Swift 4.0 or above. It's a lot easier and makes sense.
We can execute the API call when the user hasn't typed for 0.25 second.
class SearchViewController: UIViewController, UISearchBarDelegate {
// We keep track of the pending work item as a property
private var pendingRequestWorkItem: DispatchWorkItem?
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
// Cancel the currently pending item
pendingRequestWorkItem?.cancel()
// Wrap our request in a work item
let requestWorkItem = DispatchWorkItem { [weak self] in
self?.resultsLoader.loadResults(forQuery: searchText)
}
// Save the new work item and execute it after 250 ms
pendingRequestWorkItem = requestWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250),
execute: requestWorkItem)
}
}
You can read the full article about it from here
Disclamer: I am the author.
If you need vanilla Foundation based throttling feature,
If you want just one liner API without going into reactive, combine, timer, NSObject cancel and anything complex,
Throttler can be the right tool to get your job done.
You can use throttling without going reactive as below:
import Throttler
for i in 1...1000 {
Throttler.go {
print("throttle! > \(i)")
}
}
// throttle! > 1000
import UIKit
import Throttler
class ViewController: UIViewController {
#IBOutlet var button: UIButton!
var index = 0
/********
Assuming your users will tap the button, and
request asyncronous network call 10 times(maybe more?) in a row within very short time nonstop.
*********/
#IBAction func click(_ sender: Any) {
print("click1!")
Throttler.go {
// Imaging this is a time-consuming and resource-heavy task that takes an unknown amount of time!
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
guard let data = data else { return }
self.index += 1
print("click1 : \(self.index) : \(String(data: data, encoding: .utf8)!)")
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
}
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
2021-02-20 23:16:50.255273-0500 iOSThrottleTest[24776:813744]
click1 : 1 : {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
if you want some specific delay seconds:
import Throttler
for i in 1...1000 {
Throttler.go(delay:1.5) {
print("throttle! > \(i)")
}
}
// throttle! > 1000
Swift 5.0
Based on GSnyder response
//
// AutoSearchManager.swift
// BTGBankingCommons
//
// Created by Matheus Gois on 01/10/21.
//
import Foundation
/// Manage two timers to implement a standard auto search in the background.
/// Firing happens after the short interval if there are no further activations.
/// If there is an ongoing stream of activations, firing happens at least every long interval.
public class AutoSearchManager {
// MARK: - Properties
private let shortInterval: TimeInterval
private let longInterval: TimeInterval
private let callback: (Any?) -> Void
private var shortTimer: Timer?
private var longTimer: Timer?
// MARK: - Lifecycle
public init(
short: TimeInterval = Constants.shortAutoSearchDelay,
long: TimeInterval = Constants.longAutoSearchDelay,
callback: #escaping (Any?) -> Void
) {
shortInterval = short
longInterval = long
self.callback = callback
}
// MARK: - Methods
public func activate(_ object: Any? = nil) {
shortTimer?.invalidate()
shortTimer = Timer.scheduledTimer(
withTimeInterval: shortInterval,
repeats: false
) { [weak self] _ in self?.fire(object) }
if longTimer == nil {
longTimer = Timer.scheduledTimer(
withTimeInterval: longInterval,
repeats: false
) { [weak self] _ in self?.fire(object) }
}
}
public func cancel() {
shortTimer?.invalidate()
longTimer?.invalidate()
shortTimer = nil
longTimer = nil
}
// MARK: - Private methods
private func fire(_ object: Any? = nil) {
cancel()
callback(object)
}
}
// MARK: - Constants
extension AutoSearchManager {
public enum Constants {
/// Auto-search at least this frequently while typing
public static let longAutoSearchDelay: TimeInterval = 2.0
/// Trigger automatically after a pause of this length
public static let shortAutoSearchDelay: TimeInterval = 0.75
}
}

Resources