Task cancellation in SwiftUI - ios

I have come across a strange behavior (or at least one I don't understand) while trying to cancel a Task. Here is a minimal example: I have a Task that sleeps 30 seconds and then increment a counter.
However, if I call .cancel() on that Task before 30 seconds have passed then the counter is incremented immediately.
I would have expected that cancelling the Task would not increment the counter value; does anyone have an idea of what is going on here?
Thank you!
import SwiftUI
struct ContentView: View {
#State var task: Task<Void, Never>? = nil // reference to the task
#State var counter = 0
var body: some View {
VStack(spacing: 50) {
// display counter value and spawn the Task
Text("counter is \(self.counter)")
.onAppear {
self.task = Task {
try? await Task.sleep(nanoseconds: 30_000_000_000)
self.counter += 1
}
}
// cancel button
Button("cancel") {
self.task?.cancel() // <-- when tapped before 30s, counter value increases. Why?
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

When a task is canceled an error is thrown but you are ignoring the thrown error by using try?
Here is a variant of your code that will react properly to the cancellation
self.task = Task {
do {
try await Task.sleep(nanoseconds: 30_000_000_000)
self.counter += 1
} catch is CancellationError {
print("Task was cancelled")
} catch {
print("ooops! \(error)")
}

How about adding a check for isCancelled like this:
Text("counter is \(self.counter)")
.onAppear {
print("onappear..")
self.task = Task {
try? await Task.sleep(nanoseconds: 30_000_000_000)
if !self.task!.isCancelled {
self.counter += 1
}
}
}

Placing the code inside a do catch block is enough.
self.task = Task {
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
self.counter += 1
} catch {
print(error)
}
}

In SwiftUI it's best to use the .task modifier to use async/await, e.g.
#State var isStarted = false
...
Button(isStarted ? "Stop" : "Start") {
isStarted.toggle()
}
.task(id: isStarted) {
if !isStarted {
return
}
do {
try await Task.sleep(for: .seconds(3))
counter += 1
} catch {
print("Cancelled")
}
}

Related

Why is async task cancelled in a refreshable Modifier on a ScrollView (iOS 16)

I'm trying to use the refreshable modifier on a Scrollview in an app that targets iOS 16. But, the asynchronus task gets cancelled during the pull to refresh gesture.
Here is some code and an attached video that demonstrates the problem and an image with the printed error:
ExploreViemModel.swift
class ExploreViewModel: ObservableObject {
#Published var randomQuotes: [Quote] = []
init() {
Task {
await loadQuotes()
}
}
#MainActor
func loadQuotes() async {
let quotesURL = URL(string: "https://type.fit/api/quotes")!
do {
let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
if response.statusCode == 200 {
let quotes = try JSONDecoder().decode([Quote].self, from: data)
randomQuotes.append(contentsOf: quotes)
}
} catch {
debugPrint(error)
debugPrint(error.localizedDescription)
}
}
func clearQuotes() {
randomQuotes.removeAll()
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#StateObject private var exploreVM = ExploreViewModel()
var body: some View {
NavigationStack {
ExploreView()
.environmentObject(exploreVM)
.refreshable {
exploreVM.clearQuotes()
await exploreVM.loadQuotes()
}
}
}
}
Explore.swift
import SwiftUI
struct ExploreView: View {
#EnvironmentObject var exploreVM: ExploreViewModel
var body: some View {
ScrollView {
VStack {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140.0), spacing: 24.0)], spacing: 24.0) {
ForEach(exploreVM.randomQuotes) { quote in
VStack(alignment: .leading) {
Text("\(quote.text ?? "No Text")")
.font(.headline)
Text("\(quote.author ?? "No Author")")
.font(.caption)
}
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 144.0)
.border(Color.red, width: 2.0)
}
}
}
.padding()
.navigationTitle("Explore")
}
}
}
When you call exploreVM.clearQuotes() you cause the body to redraw when the array is cleared.
.refreshable also gets redrawn so the previous "Task" that is being used is cancelled.
This is just the nature of SwiftUI.
There are a few ways of overcoming this, this simplest is to "hold-on" to the task by using an id.
Option 1
struct ExploreParentView: View {
#StateObject private var exploreVM = ExploreViewModel()
//#State can survive reloads on the `View`
#State private var taskId: UUID = .init()
var body: some View {
NavigationStack {
ExploreView()
.refreshable {
print("refreshable")
//Cause .task to re-run by changing the id.
taskId = .init()
}
//Runs when the view is first loaded and when the id changes.
//Task is perserved while the id is preserved.
.task(id: taskId) {
print("task \(taskId)")
exploreVM.clearQuotes()
await exploreVM.loadQuotes()
}
}.environmentObject(exploreVM)
}
}
If you use the above method you should remove the "floating" Task you have in the init of the ExploreViewModel.
Option 2
The other way is preventing a re-draw until the url call has returned.
class ExploreViewModel: ObservableObject {
//Remove #Published
var randomQuotes: [Quote] = []
init() {
//Floading Task that isn't needed for option 1
Task {
await loadQuotes()
}
}
#MainActor
func loadQuotes() async {
let quotesURL = URL(string: "https://type.fit/api/quotes")!
do {
let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
if response.statusCode == 200 {
let quotes = try JSONDecoder().decode([Quote].self, from: data)
randomQuotes.append(contentsOf: quotes)
print("updated")
}
} catch {
debugPrint(error)
debugPrint(error.localizedDescription)
}
print("done")
//Tell the View to redraw
objectWillChange.send()
}
func clearQuotes() {
randomQuotes.removeAll()
}
}
Option 3
Is to wait to change the array until there is a response.
class ExploreViewModel: ObservableObject {
#Published var randomQuotes: [Quote] = []
init() {
Task {
await loadQuotes()
}
}
#MainActor
func loadQuotes() async {
let quotesURL = URL(string: "https://type.fit/api/quotes")!
do {
let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
if response.statusCode == 200 {
let quotes = try JSONDecoder().decode([Quote].self, from: data)
//Replace array
randomQuotes = quotes
print("updated")
}
} catch {
//Clear array
clearQuotes()
debugPrint(error)
debugPrint(error.localizedDescription)
}
print("done")
}
func clearQuotes() {
randomQuotes.removeAll()
}
}
Option 1 is more resistant to cancellation it is ok for short calls. It isn't going to wait for the call to return to dismiss the ProgressView.
Option 2 offers more control from within the ViewModel but the view can still be redrawn by someone else.
Option 3 is likely how Apple envisioned the process going but is also vulnerable to other redraws.
The point of async/await and .task is to remove the need for a reference type. Try this instead:
struct ContentView: View {
#State var randomQuotes: [Quote] = []
var body: some View {
NavigationStack {
ExploreView()
.refreshable {
await loadQuotes()
}
}
}
func loadQuotes() async {
let quotesURL = URL(string: "https://type.fit/api/quotes")!
do {
let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
if response.statusCode == 200 {
randomQuotes = try JSONDecoder().decode([Quote].self, from: data)
}
} catch {
debugPrint(error)
debugPrint(error.localizedDescription)
// usually we store the error in another state.
}
}
}

SwiftUI LiveActivities on start throw weird errors

So, I trying to work with ActivityKit, to create a simple live activity.
Code:
TimerAttributes.swift
import ActivityKit
import SwiftUI
struct TimerAttributes: ActivityAttributes {
public typealias TimerStatus = ContentState
public struct ContentState: Codable, Hashable {
var endTime: Date
}
var timerName: String
}
Widget
import ActivityKit
import WidgetKit
import SwiftUI
struct TimerActivityView: View {
let context: ActivityViewContext<TimerAttributes>
var body: some View {
VStack {
Text(context.attributes.timerName)
.font(.headline)
Text(context.state.endTime, style: .timer)
}
.padding(.horizontal)
}
}
#main
struct Tutorial_Widget: Widget {
let kind: String = "Tutorial_Widget"
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerAttributes.self) { context in
TimerActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
// 2
}
DynamicIslandExpandedRegion(.trailing) {
// 2
}
DynamicIslandExpandedRegion(.center) {
// 2
}
DynamicIslandExpandedRegion(.bottom) {
// 2
}
} compactLeading: {
// 3
} compactTrailing: {
// 3
} minimal: {
// 4
}
}
}
}
MainView.swift
import ActivityKit
import SwiftUI
struct MainView{
#State private var activity: Activity<TimerAttributes>? = nil
}
extension MainView: View {
var body: some View {
VStack(spacing: 16) {
Button("Start Activity") {
startActivity()
}
Button("Stop Activity") {
stopActivity()
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
func startActivity() {
let attributes = TimerAttributes(timerName: "Dummy Timer")
let state = TimerAttributes.TimerStatus(endTime: Date().addingTimeInterval(60 * 5))
do{
activity = try Activity.request(attributes: attributes, contentState: state)
}
catch (let error) {
print("")
print("$$$$$$$$$$")
print(error.localizedDescription)
print(error)
print(error.self)
print("$$$$$$$$$$")
print("")
}
}
func stopActivity() {
let state = TimerAttributes.TimerStatus(endTime: .now)
Task {
await activity?.end(using: state, dismissalPolicy: .immediate)
}
}
func updateActivity() {
let state = TimerAttributes.TimerStatus(endTime: Date().addingTimeInterval(60 * 10))
Task {
await activity?.update(using: state)
}
}
}
It's looks fine, but it doesn't work at all.
I'm using a widget as new target to my main App.
I'm set NSSupportsLiveActivities to YES in both Info.plist.
What I get all time after pressing the button to start activity:
Any suggestions?
I Found a Solution.
You need to Update your Xcode to version 14.1, and recreate your widget, you will find out that checkbox 'Live Activities' on create phase appears, and give you a possibility to create Live Activities.

Realm Sync Not Updating SwiftUI View After Observe is Fired

I am having issues with Realm, which is not causing my View to update. I have a checkbox in a List and when I click on the checkbox, I want to update the isCompleted to true. This part is working and it updates the isCompleted property in Realm Sync (MongoDB Atlas). The problem is that the view never re-renders again.
Here is code for my TaskCellView which updates the isCompleted.
struct TaskCellView: View {
var task: Task
let realmManager = RealmManager.shared
var body: some View {
let _ = print(Self._printChanges())
HStack {
Image(systemName: task.isCompleted ? "checkmark.square": "square")
.onTapGesture {
try! realmManager.realm.write {
task.isCompleted.toggle()
}
}
Text(task.title)
}
}
}
In my RealmManager I have setupObservers which fires and assigns the new tasks to the tasks property.
private func setupObservers() {
let observableTasks = realm.objects(Task.self)
notificationToken = observableTasks.observe { [weak self] _ in
DispatchQueue.main.async {
print(observableTasks)
self?.tasks = observableTasks
}
}
}
Then in my ContentView I use the following code to send the tasks to the TodoListView.
if let tasks = realmManager.tasksArray {
let _ = print("INSIDE IF")
let _ = print(tasks.count)
TodoListView(tasks: tasks)
}
I have checked and the print lines are getting printed but the body of the TodoListView is never executed.
TodoListView
struct TodoListView: View {
let tasks: [Task]
var pendingTasks: [Task] {
// tasks.where { $0.isCompleted == false }
tasks.filter { !$0.isCompleted }
}
var completedTasks: [Task] {
tasks.filter { $0.isCompleted }
}
var body: some View {
let _ = print(Self._printChanges())
List {
ForEach(Sections.allCases, id: \.self) { section in
Section {
ForEach(section == .pending ? pendingTasks: completedTasks, id: \._id) { task in
TaskCellView(task: task)
}
} header: {
Text(section.rawValue)
}
}
}.listStyle(.plain)
}
}
I know it is a lot of code and but maybe someone can see the problem. So even though the checkbox is checked and the Realm database is updated and observe is fired and the tasks are updated to reflect the isCompleted new states. The TodoListView is never re-rendered.

SwiftUI: Animate list after ObservedObject updates

I am attempting to animate the insertions of listItems in a List after fetching data from an API. I first show a ProgressView when the app starts calling the API, and then displays the list after API call returns.
Currently, my implementation results in a tableView.reloadData() like animation which causes a visual jerk. I would like to achieve a soft animation by using .animation(.default).
struct ContentView: View {
#ObservedObject private var manager = APIManager()
var body: some View {
NavigationView {
Group {
if manager.isLoading {
ProgressView()
} else {
List(0..<20) { i in
Text("\(i)")
.animation(.default)
}
}
}
.navigationTitle("Items")
}
}
}
class APIManager: ObservableObject {
#Published var isLoading = false
init() {
fetchData()
}
func fetchData() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
self.isLoading = false
}
}
}
}
Try to remove withAnimation {} wrapping -
func fetchData() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
// no animation needed
// withAnimation {
self.isLoading = false
// }
}
}

SwiftUI update progressBar in Thread

I'm trying to manage a chat with SwiftUI.
I found a CircularProgressBar to work with SwiftUI, in the example, it's working with Timer.
If I change the timer with an Zip extraction, the UI doesn't update.
struct DetailView: View {
var selectedChat: Chat?
#State var progressBarValue:CGFloat = 0
var body: some View {
Group {
if selectedChat != nil {
VStack {
if progressBarValue < 1 {
CircularProgressBar(value: $progressBarValue)
}
else {
//Text("WELL DONE")
Text("\(UserData().getChat(withID: selectedChat!.id)!.allText.first!.text)")
}
}
} else {
VStack {
CircularProgressBar(value: $progressBarValue)
Text("Detail view content goes here")
}
}
}.navigationBarTitle(Text("\(selectedChat?.name ?? "")"))
.onAppear {
if let chat = self.selectedChat {
if chat.allText.count == 0 {
let exData = ExtractData()
if let path = chat.getUnzipPath()?.relativePath {
DispatchQueue.main.async {//with or without the behavior is the same
exData.manageExtractedZip(unzipPath: path) { progress in
if progress >= 1 {
var newChat = chat
newChat.allText = exData.allTexts
let userD = UserData()
userD.overrideChat(with: newChat)
print(exData.allTexts)
}
self.progressBarValue = CGFloat(progress)
print("progressBarValue: \(self.progressBarValue)") //This is printing well
}
}
}
}
else {
self.progressBarValue = 1
}
}
/* This is working
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
self.progressBarValue += 0.1
print(self.progressBarValue)
if (self.progressBarValue >= 1) {
timer.invalidate()
}
}*/
}
}
}
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(selectedChat: UserData().chatData.first!)
}
}
How to make it work?

Resources