SwiftUI how to run a func after another is fully completed - swift

I am running a func that goes into a for loop and append to a an array. In the next line I am running another func that uses the first element of that array, however, the app crashes since at the time of the 2nd func execution it finds the array empty. I trued using sync() queue and completion handlers but still have the issue. The only way that it is working at the moment is to call a Timer to wait for a few seconds but that is not ideal way to do it of course. Do you have any suggestions?
The 1st func is as follows:
func openRun () {
let openPanel = NSOpenPanel()
...
if result.rawValue == NSApplication.ModalResponse.OK.rawValue {
let rawURL = openPanel.url!.path
//some codes that extract image files from the openned path
for image in imageList {
images.append(newImage)
}
}
}

It is hard to see, what you try to do. Check next Playground snippet which use NSOpenPanel to select some .swift file(s) and asynchronously (random delay mimics the real world usage) calculates length of its absolute path and show the results in SwiftUI View.
//: A Cocoa based Playground to present user interface
import AppKit
import SwiftUI
import PlaygroundSupport
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.allowedFileTypes = ["swift"]
struct Info: Identifiable {
let id = UUID()
let txt: String
let length: Int
}
struct ContentView: View {
#State var arr: [Info] = []
var body: some View {
VStack {
Button(action: {
panel.begin { (respond) in
panel.urls.forEach { (url) in
self.urlLength(url: url) { (i) in
self.arr.append(Info(txt: url.lastPathComponent, length: i))
}
}
}
}) {
Text("action")
}.padding()
List(arr) { (item) in
HStack {
Text(item.txt)
Text(item.length.description).foregroundColor(Color.yellow)
}
}
}.frame(width: 200, height: 400)
.border(Color.red)
}
func urlLength(url: URL, completion: #escaping (Int)->()) {
DispatchQueue.global().asyncAfter(deadline: .now() + Double.random(in: 0.0 ..< 3.0)) { [url] in
let c = url.absoluteString.count
DispatchQueue.main.async {
completion(c)
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
this funny example demonstrates how to use asynchronous code with SwiftUI

Related

SwiftUI unwrapping optionals inside View (pyramid of doom)

I have a sample program that does three things
Generate a random integer from -10...10 (regular function)
Generate 10 million random numbers from -10...10 (asynchronous function)
Calculate the average of #2 (throwing asynchronous function)
Below is the full working code. It works without errors, but the view has a horrible readability with three nested if/let loops. What's the best way/convention to get rid of the pyramid of doom in this scenario?
Result screenshot (how it should work)
Working code (methods)
class NumberManager: ObservableObject {
#Published var integer: Int?
#Published var numbers: [Double]?
#Published var average: Double?
func generateInt() {
self.integer = Int.random(in: -10...10)
}
func generateNumbers() async {
self.numbers = (1...10_000_000).map { _ in Double.random(in: -10...10) }
// takes about 5 seconds to run...
}
func calculateAverageNumber(for numbers: [Double]) async throws {
guard !numbers.isEmpty else {
print("numbers not generated")
return
}
let total = numbers.reduce(0, +)
let average = total / Double(numbers.count)
self.average = average
}
}
Working code (view)
struct ContentView: View {
#StateObject var numberManager = NumberManager()
var body: some View {
if let integer = numberManager.integer {
if let numbers = numberManager.numbers {
if let average = numberManager.average {
Text("Integer is \(integer)")
Text("First number is: \(numbers[0])")
Text("Average is: \(average)")
} else {
LoadingView(loadingType: "Calculating average")
.task {
do {
try await numberManager.calculateAverageNumber(for: numbers)
} catch {
print("empty numbers array")
}
}
}
} else {
LoadingView(loadingType: "Generating numbers")
.task {
await numberManager.generateNumbers()
}
}
} else {
LoadingView(loadingType: "Generating int")
.task {
numberManager.generateInt()
}
}
}
}
What I tried so far...
I tried building helper functions to build views as below, and called those functions that returns views inside my ContentView. When I run it, the integer and the number array gets generated and shows, but the last task that calculates the average does not get called again at all.
Result screenshot(with issues)
Code (Runs without errors. But the last task that calculates average doesn't get executed)
struct ContentView: View {
#StateObject var numberManager = NumberManager()
var body: some View {
intergerView()
.task {
print("Generating Int")
numberManager.generateInt()
}
numbersView()
.task {
print("Generating Numbers")
await numberManager.generateNumbers()
}
averageView()
.task {
do {
print("Calculating Average")
try await numberManager.calculateAverageNumber(for: numberManager.numbers ?? [])
} catch {
print("error")
}
}
}
}
private func intergerView() -> some View {
guard let integer = numberManager.integer else {
return AnyView(LoadingView(loadingType: "Generating int"))
}
return AnyView(Text("Integer is \(integer)"))
}
private func numbersView() -> some View {
guard let numbers = numberManager.numbers else {
return AnyView(LoadingView(loadingType: "Generating numbers"))
}
return AnyView(Text("First number is: \(numbers[0])"))
}
private func averageView() -> some View {
guard let average = numberManager.average else {
return AnyView(LoadingView(loadingType: "Calculating average"))
}
return AnyView(Text("Average is: \(average)"))
}
EDIT: In my app, I have a view that does all different functions in one view (it's like a dashboard). Some require others to run first (like calculating the average), whereas some can run on its own (Like generating one random integer). I want to display whatever that's loaded first, while displaying a loadingview placeholder for parts that aren't loaded yet.
Several issues here:
generateNumbers and calculateAverageNumber depend on each other. So they need to await each other.
your "working code" does not match the description of your code. You say you want to show what ever finishes first but your if/else statements introduce dependencies between all 3 functions/views
you donĀ“t need 3 different views. One that can be customized should be enough.
class NumberManager: ObservableObject {
#Published var integer: Int?
#Published var numbers: [Double]?
#Published var average: Double?
func generateInt() {
self.integer = Int.random(in: -10...10)
}
func generateNumbers() async {
self.numbers = (1...10_000_000).map { _ in Double.random(in: -10...10) }
// takes about 5 seconds to run...
}
// No need for arguments here
func calculateAverageNumber() async throws {
guard let numbers = numbers, !numbers.isEmpty else {
print("numbers not generated")
return
}
let total = numbers.reduce(0, +)
let average = total / Double(numbers.count)
self.average = average
}
//This function will handle the dependenies of generating the values and calculating the avarage
func calculateNumbersAndAvarage() async throws{
await generateNumbers()
try await calculateAverageNumber()
}
}
The View:
struct ContentView: View{
#StateObject private var numberManager = NumberManager()
var body: some View{
//Show the different detail views.
VStack{
Spacer()
Spacer()
DetailView(text: numberManager.integer != nil ? "Integer is \(numberManager.integer!)" : nil)
.onAppear {
numberManager.generateInt()
}
Spacer()
Group{
DetailView(text: isNumbersValid ? "First number is: \(numberManager.numbers![0])" : nil)
Spacer()
DetailView(text: numberManager.average != nil ? "Average is: \(numberManager.average!)" : nil)
}.onAppear {
Task{
do{
try await numberManager.calculateNumbersAndAvarage()
}
catch{
print("error")
}
}
}
Spacer()
Spacer()
}
}
//Just to make it more readable
var isNumbersValid: Bool{
numberManager.numbers != nil && numberManager.numbers?.count != 0
}
}
and the DetailView:
struct DetailView: View{
let text: String?
var body: some View{
// If no text to show, show `ProgressView`, or `LoadingView` in your case. You can inject the view directly or use a property for the String argument.
if let text = text {
Text(text)
.font(.headline)
.padding()
} else{
ProgressView()
}
}
}
The code should speak for itself. If you have any further question regarding this code please feel free to do so, but please read and try to understand how this works first.
Edit:
This does not wait for calculateAverageNumber to finish before displaying numbers[0]. The reason for it showing at the same time is that it takes almost no time to calculat the avarage. Try adding this between the 2 functions in calculateNumbersAndAvarage.
try await Task.sleep(nanoseconds: 4_000_000_000)
and you will see that it shows as it should.
You are almost done. Use #ViewBuilder , remove AnyView wrapper and dont use guard
#ViewBuilder
var intergerView: some View {
if let integer = numberManager.integer {
LoadingView(loadingType: "Generating int")
} else {
Text("Integer is \(integer)")
}
}

How to load lots of data from a paged api in SwiftUI efficiently?

I have been trying to load some data from a backend server and it comes in page size of 10 elements per page.
I am using combine and the issue is i am getting back videos and images from the server as well.
How do i load them properly? I have tried adding reloading functionality but it all just slows down the app massively. This is my code right now:
class ProductDataService: ObservableObject {
#Published var count = 1
#Published var countPage = 0
#Published var allProducts: [ListedItem] = []
var productSubscription: AnyCancellable?
init() {
getProducts()
}
func getProducts() {
guard let url = URL(string: "myBackendURL-api/product/?page=\(count)") else{return}
productSubscription = NetworkingManager.download(url: url)
.decode(type: Product.self, decoder: JSONDecoder())
.sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedProducts in
self?.allProducts = returnedProducts.results
self?.countPage = returnedProducts.count
self?.nextPageView = returnedProducts.next
self?.productSubscription?.cancel()
})
}
}
i would add the code for networking manager below as well, it's nothing unsual tho
my backend returns a json like this:
"count": 33,
"next": "http://backend/product/?page=2",
"previous": null,
"results": [
{
"id": 79,
"productName": "Mango",
"producDescription": "Banana",
"product_image1": "https:something.png",
},
For now I tried using the next parameter in every call to replace with the url property of my function but couldn't do it properly.
Right now am using the count of the total products and then inside my home view model i use:
private let dataService = ProductDataService()
private var cancellables = Set<AnyCancellable>()
#Published var allProducts: [ListedItem] = []
#Published var tempArray: [ListedItem] = [] // listeditem is one product
init() {
addSubscribers()
}
func addSubscribers() {
dataService.$allProducts
.sink { [weak self] (returnedProducts) in
self?.allProducts += returnedProducts
}
.store(in: &cancellables)
}
And then i use a scrollview reader to read if the person reached the end of the screen to load more items.
I call this function from my view:
func loadMoreData() {
if allProducts.count < dataService.countPage {
if dataService.count < Int(dataService.countPage/10) {
print("our comparing value is \(Int(dataService.countPage/10))")
dataService.count += 1
dataService.getProducts()
}
else {
dataService.count -= 1
}
print("value of count is : \(dataService.count)")
}
else {
print("our product count is more ")
}
}
But it doesn't work exactly how i want it to be.
First it just skips page 2 idk why and directly loads page 3 items. and now i have 33 items that is till page no. 4 but it never goes there due to my checks tho i can write +1 in my if conditon and it would work but still
It also hangs the screen for 2 secs when loading.
How do i show some progress view at that moment?
Where do i but the boolean for that?
Also is there a better way to load items from a GET Api rather than this?
Now I would add my image problem:
Now inside my product image view am dealing with videos and images like this:
var body: some View {
if vm.image != nil {
if let image = vm.image {
Image(uiImage: image)
}
else if vm.isloading {
ProgressView()
}
}
else if let stringUrl = Optional(vm.product.product_image1) {
ZStack {
customVideoPlayer(player: player)
.onAppear {
player.isMuted = isVideoMuted
if player.currentItem == nil {
let item = AVPlayerItem(url: URL(string: stringUrl) !)
player.replaceCurrentItem(with: item)
}
}
}
}
}
The issue here is that before loading any image it takes in the frame of a video. And then it loads as an image but why so? I don't understand at all. I want it to either show a loading screen but not blank video screens.

Firestore method in view model not invoking in SwiftUI onAppear method

I want to implement a Text field that displays the current user's existing score in the DB (Firestore). Because of the nature of async in Firebase query, I also need to do some adjustment in my codes. However, it seems that completion() handler does not work well:
// ViewModel.swift
import Foundation
import Firebase
import FirebaseFirestore
class UserViewModel: ObservableObject {
let current_user_id = Auth.auth().currentUser!.uid
private var db = Firestore.firestore()
#Published var xp:Int?
func fetchData(completion: #escaping () -> Void) {
let docRef = db.collection("users").document(current_user_id)
docRef.getDocument { snapshot, error in
print(error ?? "No error.")
self.xp = 0
guard let snapshot = snapshot else {
completion()
return
}
self.xp = (snapshot.data()!["xp"] as! Int)
completion()
}
}
}
// View.swift
import SwiftUI
import CoreData
import Firebase
{
#ObservedObject private var users = UserViewModel()
var body: some View {
VStack {
HStack {
// ...
Text("xp: \(users.xp ?? 0)")
// Text("xp: 1500")
.fontWeight(.bold)
.padding(.horizontal)
.foregroundColor(Color.white)
.background(Color("Black"))
.clipShape(CustomCorner(corners: [.bottomLeft, .bottomRight, .topRight, .topLeft], size: 3))
.padding(.trailing)
}
.padding(.top)
.onAppear() {
self.users.fetchData()
}
// ...
}
}
My result kept showing 0 in Text("xp: \(users.xp ?? 0)"), which represents that the step is yet to be async'ed. So what can I do to resolve it?
I would first check to make sure the data is valid in the Firestore console before debugging further. That said, you can do away with the completion handler if you're using observable objects and you should unwrap the data safely. Errors can always happen over network calls so always safely unwrap anything that comes across them. Also, make use of the idiomatic get() method in the Firestore API, it makes code easier to read.
That also said, the problem is your call to fetch data manually in the horizontal stack's onAppear method. This pattern can produce unsavory results in SwiftUI, so simply remove the call to manually fetch data in the view and perform it automatically in the view model's initializer.
class UserViewModel: ObservableObject {
#Published var xp: Int?
init() {
guard let uid = Auth.auth().currentUser?.uid else {
return
}
let docRef = Firestore.firestore().collection("users").document(uid)
docRef.getDocument { (snapshot, error) in
if let doc = snapshot,
let xp = doc.get("xp") as? Int {
self.xp = xp
} else if let error = error {
print(error)
}
}
}
}
struct ContentView: View {
#ObservedObject var users = UserViewModel()
var body: some View {
VStack {
HStack {
Text("xp: \(users.xp ?? 0)")
}
}
}
}
SwiftUI View - viewDidLoad()? is the problem you ultimately want to solve.

Swift: return value every x seconds

I'm trying to generate random string every 10 seconds. I put this function in a class in another file and will call the function in another view controller. But now I'm not getting any output when I call it. How to can I fix this code
class Data{
static let instance = Data()
func randomString(of length: Int){
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var s = ""
for _ in 0 ..< length {
s.append(letters.randomElement()!)
print("\(s) = I'm in randomString Func")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
self?.randomString(of: 5)
}
}
}
in a view controller I put it under a button action and call it with this code
Button(action: {
info = Data.instance.randomString(of:5)
print(info)
}, label: {
Text ("PRINT")
.font(.callout)
.foregroundColor(Color.primary)
})
A possible solution is to use the Timer from the Combine framework:
struct ContentView: View {
#State private var text = "initial text"
#State private var timer: AnyCancellable?
var body: some View {
Text(text)
Button(action: startTimer) { // start timer on button tap, alternatively put it in `onAppear`
Text("Start timer")
}
}
func startTimer() {
// start timer (tick every 10 seconds)
timer = Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.sink { _ in
text = DataGenerator.instance.randomString(of: 5)
}
}
}
You also need to return the String from the randomString function. A good thing would also be to rename Data to avoid collisions:
class DataGenerator { // rename the class
static let instance = DataGenerator()
func randomString(of length: Int) -> String { // return `String`
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var s = ""
for _ in 0 ..< length {
s.append(letters.randomElement()!)
print("\(s) = I'm in randomString Func")
}
return s // return String, don't call `DispatchQueue.main.async` here
}
}
You may also consider moving the timer logic out of the view like in:
How to make the View update instant in SwiftUI?

How to reduce the memory footprint of a large list of images in SwiftUI

I'm playing with SwiftUI and I'm currently struggling with images. Basically, I want to display an list of images with an infinite scroll but I want to keep the memory usage reasonable.
I have the following (truncated) code:
struct HomeView: View {
#State private var wallpapers: Loadable<[Wallpaper]> = .notRequested
#State private var currentPage = 1
#Environment(\.container) private var container
var body: some View {
content
.onAppear { loadWallpapers() }
}
private var content: some View {
VStack {
wallpapersList(data: wallpapers.value ?? [])
// ...
}
}
private func wallpapersList(data: [Wallpaper]) -> some View {
ScrollView {
LazyVStack(spacing: 5) {
ForEach(data) { w in
networkImage(url: w.thumbs.original)
.onAppear { loadNextPage(current: w.id) }
}
}
}
}
private func networkImage(url: String) -> some View {
// I use https://github.com/onevcat/Kingfisher to handle image loading
KFImage(URL(string: url))
// ...
}
private func loadWallpapers() {
container.interactors.wallpapers.load(data: $wallpapers, page: currentPage)
}
private func loadNextPage(current: String) {
// ...
}
}
struct WallpapersInteractor: PWallpapersInteractor {
let state: Store<AppState>
let agent: PWallpapersAgent
func load(data: LoadableSubject<[Wallpaper]>, page: Int) {
let store = CancelBag()
data.wrappedValue.setLoading(store: store)
Just.withErrorType((), Error.self)
.flatMap { _ in
agent.loadWallpapers(page: page) // network call here
}
.map { response in
response.data
}
.sink { subCompletion in
if case let .failure(error) = subCompletion {
data.wrappedValue.setFailed(error: error)
}
} receiveValue: {
if var currentWallpapers = data.wrappedValue.value {
currentWallpapers.append(contentsOf: $0) // /!\
data.wrappedValue.setLoaded(value: currentWallpapers)
} else {
data.wrappedValue.setLoaded(value: $0)
}
}
.store(in: store)
}
}
Because I append the new data to my Binding every time I request a new batch of images, the memory consumption quickly becomes stupidly high.
I tried to remove data from the array using .removeFirst(pageSize) once I get to the third page so that my array contains at most 2 * pageSize elements (pageSize being 64 in this case). But doing so makes my list all jumpy because the content goes up, which creates more problems than it solves.
I tried searching for a solution but I surprisingly didn't find anything on this particular topic, am I missing something obvious ?