3

Scenario

A simple SwiftUI App that consists of a TabView with two tabs. The App struct has a @StateObject property, which is being repeatedly and very quickly (30 times per second) updated by simulateFastStateUpdate.

In this example, simulateFastStateUpdate is not doing any useful work, but it closely resembles a real function that quickly updates the app's state. The function does some work on a background queue for a short interval of time and then schedules a state update on the main queue. For example, when using the camera API, the app might update the preview image as frequently as 30 times per second.

Question

When the app is running, the TabView does not respond to taps. It's permanently stuck on the first tab. Removing liveController.message = "Nice" line fixes the issue.

  1. Why is TabView stuck?
  2. Why is updating @StateObject causing this issue?
  3. How to adapt this simple example, so that the TabView is not stuck?
import SwiftUI

class LiveController: ObservableObject {
    @Published var message = "Hello"
}

@main
struct LiveApp: App {
    @StateObject var liveController = LiveController()

    var body: some Scene {
        WindowGroup {
            TabView() {
                Text(liveController.message)
                    .tabItem {
                        Image(systemName: "1.circle")
                    }
                Text("Tab 2")
                    .tabItem {
                        Image(systemName: "2.circle")
                    }
            }
            .onAppear {
                DispatchQueue.global(qos: .userInitiated).async {
                    simulateFastStateUpdate()
                }
            }
        }
    }
    
    func simulateFastStateUpdate() {
        DispatchQueue.main.async {
            liveController.message = "Nice"
        }
        
        // waits 33 ms ~ 30 updates per second
        usleep(33 * 1000)
        
        DispatchQueue.global(qos: .userInitiated).async {
            simulateFastStateUpdate()
        }
    }
}
hellodanylo
  • 380
  • 3
  • 15
  • 1
    using your code as is, works without any problems for me on macos 12.beta, xcode 13.beta, target ios 15 and macCatalyst. Tested on iPhone ios15 and macos 12. Maybe your issue happens on older ios, macos. Are you using this in Preview? – workingdog support Ukraine Jul 20 '21 at 07:23
  • @workingdog I tested this on iOS 14 with simulator and iPhone 11 Pro Max. – hellodanylo Jul 20 '21 at 13:00

1 Answers1

2

You are blocking the main thread with these constant updates and the app is busy processing your UI updates and can't handle touch inputs (also received on the main thread).

Whatever creates this rapid event stream needs to be throttled. You can use Combine's throttle or debounce functionality to reduce the frequency of your UI updates.

Look at this sample, I added the class UpdateEmittingComponent producing updates with a Timer. This could be your background component updating rapidly.

In your LiveController I'm observing the result with Combine. There I added a throttle into the pipeline which will cause the message publisher to fiere once per second by dropping all in-between values.

Removing the throttle will end up in an unresponsive TabView.

import SwiftUI
import Combine

/// class simulating a component emitting constant events
class UpdateEmittingComponent: ObservableObject {
    @Published var result: String = ""
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = Timer
            .publish(every: 0.00001, on: .main, in: .default)
            .autoconnect()
            .sink {
                [weak self] _ in
                self?.result = "\(Date().timeIntervalSince1970)"
            }
    }
}

class LiveController: ObservableObject {
    @Published var message = "Hello"
    
    @ObservedObject var updateEmitter = UpdateEmittingComponent()
    private var cancellable: AnyCancellable?
    
    init() {
        updateEmitter
            .$result
            .throttle(for: .seconds(1),
                scheduler: RunLoop.main,
                latest: true
            )
            .assign(to: &$message)
    }
}

@main
struct LiveApp: App {
    @StateObject var liveController = LiveController()

    var body: some Scene {
        WindowGroup {
            TabView() {
                Text(liveController.message)
                    .tabItem {
                        Image(systemName: "1.circle")
                    }
                Text("Tab 2")
                    .tabItem {
                        Image(systemName: "2.circle")
                    }
            }
        }
    }
}
mgratzer
  • 620
  • 1
  • 5
  • 8
  • works without any problems without the throttle for me on macos 12.beta, xcode 13.beta, target ios 15 and macCatalyst. Tested on iPhone ios15 and macos 12. – workingdog support Ukraine Jul 20 '21 at 08:18
  • Interesting, I have no input response on an iPhone 7 test device running iOS14. – mgratzer Jul 20 '21 at 08:59
  • it is probably to do with the ios, because I have an older iPad, but with ios15, and it works well. I can see the "liveController.message" going crazy, but the TabView is responsive, just like nothing is happening. – workingdog support Ukraine Jul 20 '21 at 09:54
  • Thanks for a throttling example. Any suggestions if throttling is not an option? – hellodanylo Jul 20 '21 at 14:06
  • I don't see how throttling should not work in this case? You can always write to a separate publisher and subscribe with throttling. But you will have to add an indirection with a component like the `UpdateEmittingComponent`. What is creating these rapid updates? Might be easier to reason with more details. – mgratzer Jul 20 '21 at 14:14
  • @mgratzer the app really needs to update the state 30 times/sec. My particular use case is drawing a sequence of images that are generated on the fly at 30 fps. – hellodanylo Jul 20 '21 at 16:20