Swift UI overwhelmed by high-frequency #StateObject updates? - ios

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.
Why is TabView stuck?
Why is updating #StateObject causing this issue?
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()
}
}
}

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

Related

Best way to achieve animation in SwiftUI with a CurrentValueSubject value change

I’m currently building out a new MVVM app in SwiftUI and wanted to see whether there was a cleaner way of assigning a value from a CurrentValueSubject to my ViewModel, whilst still achieving animations? 

I’m currently leaning toward solution 2 below as that keeps the animation code neatly within the View, but it’s quite annoying having to setup another State for every animated change. Hoping there’s another way!
Solution 1 - use sink and withAnimation together in the ViewModel:
ViewModel:
authenticationService.authenticationState
.receive(on: DispatchQueue.main)
.sink { [unowned self] state in
withAnimation(.easeInOut(duration: Constants.Animation.slideSpeed)) {
self.authenticationState = state
}
}
.store(in: &cancellables)
Solution 2 - Use assign in the ViewModel and onReceive (+ withAnimation) in the view
ViewModel:
authenticationService.authenticationState
.receive(on: DispatchQueue.main)
.assign(to: &$authenticationState)
View:
struct ContentView: View {
#StateObject private var viewModel = ViewModel()
#State private var authenticationState: AuthenticationState = .loggedOut
private var shiftAnimation: AnyTransition {
.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}
var body: some View {
ZStack {
switch authenticationState {
case .loggedOut:
LoginView()
.transition(shiftAnimation)
case .loggedIn:
AdminView()
.transition(shiftAnimation)
}
if viewModel.showProgressView {
ProgressView()
}
}
.onReceive(viewModel.$authenticationState) { state in
withAnimation(.easeInOut(duration: Constants.Animation.slideSpeed)) {
self.authenticationState = state
}
}
}
}

Why does .delay break this short piece of code using Combine framework in swift

Swift 5.x iOS 14
Wrote this code, trying understand the sequence publisher in the Combine Framework.
struct SwiftUIViewH: View {
#State var textColor = Color.black
var body: some View {
Text("Hello, World!")
.foregroundColor(textColor)
.onAppear {
let initialSequence = [Color.red, Color.blue, Color.green, Color.orange]
_ = initialSequence.publisher
.delay(for: 1.0, scheduler: RunLoop.main)
.sink {
textColor = $0
print($0)
}
}
}
}
It works, in that I goes thru the list in milli-seconds and changes the colour of hello World if I don't try and slow the process down with a delay? But with delay in place as you see here, it does nothing it seems... the code above is broken?
This happens because you do not store Cancellable returned after subscription. As soon as Cancellable is deallocated the whole subscription is cancelled.
Without delay everything works because subscriber is called immediatly, right after subscription.
Add property to your view:
#State var cancellable: AnyCancellable?
And save Cancellable returned after subscription:
cancellable = initialSequence.publisher
However your code won't add delay between each color change. All colors are sent immediatly -> you add delay to each event -> after 1 sec all colors are sent to subscriber :) .

SwiftUI Schedule Local Notification Without Button?

This may have a very simple answer, as I am pretty new to Swift and SwiftUI and am just starting to learn. I'm trying to schedule local notifications that will repeat daily at a specific time, but only do it if a toggle is selected. So if a variable is true, I want that notification to be scheduled. I looked at some tutorials online such as this one, but they all show this using a button. Instead of a button I want to use a toggle. Is there a certain place within the script that this must be done? What do I need to do differently in order to use a toggle instead of a button?
You can observe when the toggle is turned on and turned off -- In iOS 14 you can use the .onChange modifier to do this:
import SwiftUI
struct ContentView: View {
#State var isOn: Bool = false
var body: some View {
Toggle(isOn: $isOn, label: {
Text("Notifications?")
})
/// right here!
.onChange(of: isOn, perform: { toggleIsOn in
if toggleIsOn {
print("schedule notification")
} else {
print("don't schedule notification")
}
})
}
}
For earlier versions, you can try using onReceive with Combine:
import SwiftUI
import Combine
struct ContentView: View {
#State var isOn: Bool = false
var body: some View {
Toggle(isOn: $isOn, label: {
Text("Notifications?")
})
/// a bit more complicated, but it works
.onReceive(Just(isOn)) { toggleIsOn in
if toggleIsOn {
print("schedule notification")
} else {
print("don't schedule notification")
}
}
}
}
You can find even more creative solutions to observe the toggle change here.

How can I avoid this SwiftUI + Combine Timer Publisher reference cycle / memory leak?

I have the following SwiftUI view which contains a subview that fades away after five seconds. The fade is triggered by receiving the result of a Combine TimePublisher, but changing the value of showRedView in the sink publisher's sink block is causing a memory leak.
import Combine
import SwiftUI
struct ContentView: View {
#State var showRedView = true
#State var subscriptions: Set<AnyCancellable> = []
var body: some View {
ZStack {
if showRedView {
Color.red
.transition(.opacity)
}
Text("Hello, world!")
.padding()
}
.onAppear {
fadeRedView()
}
}
func fadeRedView() {
Timer.publish(every: 5.0, on: .main, in: .default)
.autoconnect()
.prefix(1)
.sink { _ in
withAnimation {
showRedView = false
}
}
.store(in: &subscriptions)
}
}
I thought this was somehow managed behind the scenes with the AnyCancellable collection. I'm relatively new to SwiftUI and Combine, so sure I'm either messing something up here or not thinking about it correctly. What's the best way to avoid this leak?
Edit: Adding some pictures showing the leak.
Views should be thought of as describing the structure of the view, and how it reacts to data. They ought to be small, single-purpose, easy-to-init structures. They shouldn't hold instances with their own life-cycles (like keeping publisher subscriptions) - those belong to the view model.
class ViewModel: ObservableObject {
var pub: AnyPublisher<Void, Never> {
Timer.publish(every: 2.0, on: .main, in: .default).autoconnect()
.prefix(1)
.map { _ in }
.eraseToAnyPublisher()
}
}
And use .onReceive to react to published events in the View:
struct ContentView: View {
#State var showRedView = true
#ObservedObject vm = ViewModel()
var body: some View {
ZStack {
if showRedView {
Color.red
.transition(.opacity)
}
Text("Hello, world!")
.padding()
}
.onReceive(self.vm.pub, perform: {
withAnimation {
self.showRedView = false
}
})
}
}
So, it seems that with the above arrangement, the TimerPublisher with prefix publisher chain is causing the leak. It's also not the right publisher to use for your use case.
The following achieves the same result, without the leak:
class ViewModel: ObservableObject {
var pub: AnyPublisher<Void, Never> {
Just(())
.delay(for: .seconds(2.0), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
My guess is that you're leaking because you store an AnyCancellable in subscriptions and you never remove it.
The sink operator creates the AnyCancellable. Unless you store it somewhere, the subscription will be cancelled prematurely. But if we use the Subscribers.Sink subscriber directly, instead of using the sink operator, there will be no AnyCancellable for us to manage.
func fadeRedView() {
Timer.publish(every: 5.0, on: .main, in: .default)
.autoconnect()
.prefix(1)
.subscribe(Subscribers.Sink(
receiveCompletion: { _ in },
receiveValue: { _ in
withAnimation {
showRedView = false
}
}
))
}
But this is still overkill. You don't need Combine for this. You can schedule the event directly:
func fadeRedView() {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
withAnimation {
showRedView = false
}
}
}

Swift DispatchSourceTimer makes unrelated views reload every second

[28/10/2019: I have recreated the same test project with UIKit... unsurprisingly, the timer does not make any of the view controller views reload!!! So this looks like a SwiftUI bug. UIKIt version: https://github.com/DominikButz/DispatchSourceTimerUIKit.git]
[19/10/2019: Please check out https://github.com/DominikButz/DispatchTimerSwiftUIReloadBug.git for better understanding of the issue. When you launch this in the simulator, just tap on "Launch timer". Check the debug console, you will see that all views are reloaded every second.]
Original post:
I'm creating an iOS app in SwiftUI for which I need to use a timer. I'm posting the class below.
The timer works fine (except for not continuing when the app is in the background, but that is another story...). As you can see in the resumeTimer function, the timer is set to repeat every second.
There is a view in which a Text label gets updated every time the timer fires. This works fine. However, the problem is that other views that are loaded after the timer starts all get reloaded every time the timer fires, that means every second. e.g. an unrelated list (where the timer text label is not placed) gets reloaded every second, which makes it unusable.
I know this because when I pause or cancel the timer through the user interface, the debugger shows that the views don't get reloaded every second anymore. I placed a breakpoint (with the option "continue after evaluation") in the body of each of those views and the debugger shows that the views gets reloaded every time the timer fires.
The timer class below is created in the SceneDelegate and then passed to the Content view as environment object. I had it as ObservedObject before only in the view where the timer Text label is placed, but the result is the same. See below a view that gets reloaded when the timer fires... (gif screenshot).
Could this be a bug in SwiftUI? or might there be an error in my timer class? thanks for any hints in the right direction
Here is the content view
struct ContentView: View {
#EnvironmentObject var workoutModel: WorkoutModel
#EnvironmentObject var workoutWatch: WorkoutStopWatchTimer
#State var selectedMenuIndex = UserDefaults.standard.integer(forKey: UserDefaultKeys.selectedMenuIndex)
#State private var workoutViewPresenting = false
func setMenuIndex() {
UserDefaults.standard.set(selectedMenuIndex, forKey: UserDefaultKeys.selectedMenuIndex)
}
var body: some View {
TabView(selection:$selectedMenuIndex) {
WorkoutSelectionView(workoutViewPresenting: self.$workoutViewPresenting).onAppear(perform: self.setMenuIndex)
.tabItem {
Image(systemName: "1.square.fill")
Text("Start Workout")
}.tag(0)
Text("History").onAppear(perform: self.setMenuIndex) // I set a breakpoint here (with continue after evaluation checked)
.tabItem {
Image(systemName: "2.square.fill")
Text("History")
}.tag(1)
ExerciseListView().onAppear(perform: self.setMenuIndex) // I set a breakpoint here (with continue after evaluation checked)
.tabItem {
Image(systemName: "3.square.fill")
Text("Exercises")
}.tag(2)
Text("Profile").onAppear(perform: self.setMenuIndex)
.tabItem {
Image(systemName: "person.fill")
Text("Profile")
}.tag(3)
}.sheet(isPresented: self.$workoutViewPresenting) {
// the timer is passed to this view in which a Text label is updated every second to show the time
WorkoutView().environmentObject(self.workoutModel).environmentObject(self.workoutWatch)
}
}
}
debugger console: see the comments I put in the content view above to see where I placed the breakpoints. I did not open and close those views that many times, they are reloaded automatically every second when the timer is running.
...
3 loading History
3 loading Exercise List View
4 loading History
4 loading Exercise List View
5 loading History
5 loading Exercise List View
6 loading History
6 loading Exercise List View
7 loading History
7 loading Exercise List View
...
class WorkoutStopWatchTimer: ObservableObject {
private var sourceTimer: DispatchSourceTimer?
private let queue = DispatchQueue.init(label: "stopwatch.timer", qos: .background, attributes: [], autoreleaseFrequency: .never, target: nil)
private var counter: Int = 0 // seconds
var endDate: Date?
var duration: TimeInterval?
#Published var timeDisplayString = "0:00"
var paused = false
var isActive: Bool {
return self.sourceTimer != nil
}
func start() {
self.paused = false
guard let _ = self.sourceTimer else {
self.startTimer()
return
}
self.resumeTimer()
}
func finish() {
guard self.sourceTimer != nil else {return}
self.endDate = Date()
self.duration = TimeInterval(exactly: Double(self.counter))
self.sourceTimer?.setEventHandler {}
self.sourceTimer?.cancel()
if self.paused == true {
self.sourceTimer?.resume()
}
self.sourceTimer = nil
self.reset()
}
func pause() {
self.paused = true
self.sourceTimer?.suspend()
}
private func reset() {
self.timeDisplayString = "0:00"
self.counter = 0
}
private func startTimer() {
self.sourceTimer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags.strict,
queue: self.queue)
self.resumeTimer()
}
private func resumeTimer() {
self.sourceTimer?.setEventHandler { [weak self] in
// self.eventHandler = {
self?.updateTimer()
// }
}
self.sourceTimer?.schedule(deadline: .now(),
repeating: 1)
self.sourceTimer?.resume()
}
private func updateTimer() {
self.counter += 1
DispatchQueue.main.async {
self.timeDisplayString = WorkoutStopWatchTimer.convertCountToTimeString(counter: self.counter)
}
}
}

Resources