Displaying activity indicator while loading async request in SwiftUI - ios

I have a basic view that displays a list that fetches data from an API. I want to implement an activity indicator while the data is being retrieved from the API. In MVC we could use a delegate and protocol and make the view controller inherit the protocol, and after the model has finished fetching the data we call the delegate to tell the view controller that the data has finished retrieving (now hide the activity indicator, etc.). How to achieve a similar thing in SwiftUI and it's MVVM style?
I have tried implementing an activity indicator from this question, I just don't get how and when to stop it: Activity indicator in SwiftUI
My SourcesViewModel (it fetches news article sources from newsapi.org)
import UIKit
class SourcesViewModel: Identifiable {
let id = UUID()
let source: Sources
init(source: Sources) {
self.source = source
}
var name: String {
return self.source.sourceName
}
var description: String {
return self.source.sourceDescription
}
}
My SourcesListViewModel:
import Combine
class SourcesListViewModel: ObservableObject {
init() {
fetchSources()
}
#Published var sources = [SourcesViewModel]()
private func fetchSources() {
NetworkManager.shared.getSourceData { (sources) in
self.sources = sources.map(SourcesViewModel.init)
}
}
}
Lastly, my SourcesView:
import SwiftUI
struct SourcesView: View {
#ObservedObject var model = SourcesListViewModel()
var body: some View {
ActivityIndicatorView(isShowing: .constant(true)) {
NavigationView {
List(self.model.sources) { source in
VStack(alignment: .center) {
Text(source.name)
Text(source.description)
.foregroundColor(.secondary)
.lineLimit(3)
}
.navigationBarTitle(Text("Sources"), displayMode: .inline)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
SourcesView()
}
}
The result:

Your view model should have loading state, like the following
#Published var loading = false
private func fetchSources() {
self.loading = true
NetworkManager.shared.getSourceData { (sources) in
self.sources = sources.map(SourcesViewModel.init)
self.loading = false
}
}
and activity indicator should be bound to it, like
ActivityIndicatorView(isShowing: $model.loading) {

import Combine
class SourcesListViewModel: ObservableObject {
#Published var loading = true
#Published var sources = [SourcesViewModel]()
init() {
fetchSources()
}
private func fetchSources() {
NetworkManager.shared.getSourceData { (sources) in
self.sources = sources.map(SourcesViewModel.init)
self.loading=false
}
}
}
import SwiftUI
struct SourcesView: View {
#ObservedObject var model = SourcesListViewModel()
var body: some View {
ActivityIndicatorView(isShowing: .constant(self.model.loading)) {
NavigationView {
List(self.model.sources) { source in
VStack(alignment: .center) {
Text(source.name)
Text(source.description)
.foregroundColor(.secondary)
.lineLimit(3)
}
.navigationBarTitle(Text("Sources"), displayMode: .inline)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
SourcesView()
}
}

Related

How to properly implement a global variable in SwiftUI

I am going to create a SwiftUI application where I want to be able to swap between 3 modes. I am trying EnvironmentObject without success. I am able to change the view displayed locally, but from another View (in the end will be a class) I get a
fatal error: No ObservableObject of type DisplayView found. A View.environmentObject(_:) for DisplayView may be missing as an ancestor of this view.
Here is my code. The first line of the ContentView if/else fails.
enum ViewMode {
case Connect, Loading, ModeSelection
}
class DisplayView: ObservableObject {
#Published var displayMode: ViewMode = .Connect
}
struct ContentView: View {
#EnvironmentObject var viewMode: DisplayView
var body: some View {
VStack {
if viewMode.displayMode == .Connect {
ConnectView()
} else if viewMode.displayMode == .Loading {
LoadingView()
} else if viewMode.displayMode == .ModeSelection {
ModeSelectView()
} else {
Text("Error.")
}
TestView() //Want this to update the var & change UI.
}
.environmentObject(viewMode)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(DisplayView())
}
}
//FAILS
struct TestView: View {
#EnvironmentObject var showView: DisplayView
var body: some View {
HStack {
Button("-> load") {
self.showView.displayMode = .Loading
}
}
}
}
struct ConnectView: View {
var body: some View {
Text("Connect...")
}
}
struct LoadingView: View {
var body: some View {
Text("Loading...")
}
}
struct ModeSelectView: View {
var body: some View {
Text("Select Mode")
}
}
I would like to be able to update DisplayView from anywhere and have the ContentView UI adapt accordingly. I can update from within ContentView but I want to be able update from anywhere and have my view change.
I needed to inject BEFORE - so this fixed things up:
#main
struct fooApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(DisplayView()) //super key!
}
}
}
I also tried a Singleton class to store some properties - and thus they are available from anywhere and can be updated anywhere - without having to declare EnvironmentObject. It's just another way that can work in different circumstances.
class PropContainerModel {
public var foo = "Hello"
static let shared = PropContainerModel()
private override init(){}
}
And then somewhere else
let thisFoo = PropContainerModel.shared.foo
//
PropContainerModel.shared.foo = "There"
Update here (Singleton but changes reflect in the SwiftUI UI).
class PropContainerModel: ObservableObject
{
#Published var foo: String = "Foo"
static let shared = PropContainerModel()
private init(){}
}
struct ContentView: View
{
#ObservedObject var propertyModel = PropContainerModel.shared
var body: some View {
VStack {
Text("foo = \(propertyModel.foo)")
.padding()
Button {
tapped(value: "Car")
} label: {
Image(systemName:"car")
.font(.system(size: 24))
.foregroundColor(.black)
}
Spacer()
.frame(height:20)
Button {
tapped(value: "Star")
} label: {
Image(systemName:"star")
.font(.system(size: 24))
.foregroundColor(.black)
}
}
}
func tapped(value: String)
{
PropContainerModel.shared.foo = value
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Why is my .onAppear not getting triggered when an EnvironmentObject changes?

I'm trying to learn SwiftUI, but i can't seem to get my view to update. I want my WorkoutsView to refresh with the newly added workout when the user presses the "Add" button:
WorkoutTrackerApp:
#main
struct WorkoutTrackerApp: App {
var body: some Scene {
WindowGroup {
WorkoutTrackerView()
}
}
}
extension WorkoutTrackerApp {
struct WorkoutTrackerView: View {
#StateObject var workoutService = WorkoutService.instance
var body: some View {
NavigationView {
WorkoutsView { $workout in
NavigationLink(destination: WorkoutView(workout: $workout)){
Text(workout.title)
}
}
.toolbar {
Button("Add") {
workoutService.addNewWorkout()
}
}
.navigationTitle("Workouts")
}
.environmentObject(workoutService)
}
}
}
WorkoutsView:
import Foundation
import SwiftUI
struct WorkoutsView<Wrapper>: View where Wrapper: View {
#EnvironmentObject var workoutService: WorkoutService
#StateObject var viewModel: ViewModel
let workoutWrapper: (Binding<Workout>) -> Wrapper
init(_ viewModel: ViewModel = .init(), workoutWrapper: #escaping (Binding<Workout>) -> Wrapper) {
_viewModel = StateObject(wrappedValue: viewModel)
self.workoutWrapper = workoutWrapper
}
var body: some View {
List {
Section(header: Text("All Workouts")) {
ForEach($viewModel.workouts) { $workout in
workoutWrapper($workout)
}
}
}
.onAppear {
viewModel.workoutService = self.workoutService
viewModel.getWorkouts()
}
}
}
extension WorkoutsView {
class ViewModel: ObservableObject {
#Published var workouts = [Workout]()
var workoutService: WorkoutService?
func getWorkouts() {
workoutService?.getWorkouts { workouts in
self.workouts = workouts
}
}
}
}
WorkoutService:
import Foundation
class WorkoutService: ObservableObject {
static let instance = WorkoutService()
#Published var workouts = [Workout]()
private init() {
for i in 0...5 {
let workout = Workout(id: i, title: "Workout \(i)", exercises: [])
workouts.append(workout)
}
}
func getWorkouts(completion: #escaping ([Workout]) -> Void) {
DispatchQueue.main.async {
completion(self.workouts)
}
}
func addNewWorkout() {
let newWorkout = Workout(title: "New Workout")
workouts = workouts + [newWorkout]
}
}
The .onAppear in WorkoutsView only gets called once - when the view gets initialised for the first time. I want it to also get triggered when workoutService.addNewWorkout() gets called.
FYI: The WorkoutService is a 'mock' service, in the future i want to call an API there.
Figured it out, changed the body of WorkoutsView to this:
var body: some View {
List {
Section(header: Text("All Workouts")) {
ForEach($viewModel.workouts) { $workout in
workoutWrapper($workout)
}
}
}
.onAppear {
viewModel.workoutService = self.workoutService
viewModel.getWorkouts()
}
.onReceive(workoutService.objectWillChange) {
viewModel.getWorkouts()
}
}
Now the workouts list gets refreshed when workoutService publisher emits. The solution involved using the .onReceive to do something when the WorkoutService changes.

SwiftUI iOS 14 View does not Update #Published Array with #EnvironmentObject

I'm working on a calorie-tracker app.
In my App, I can open the Detail side of some products, set the amount and add the product to the "Cart".
Later, I want to read out all collected datas from the array and show them an a short overview.
But this View won't be updated after making changer on the array.
Due to I storage the datas with the userDefaults, I always have to reopen the app to update the view. Only then, the hole array will be displayed.
My Class Cart:
import Foundation
struct Order: Equatable, Identifiable, Codable {
var id = UUID()
var product: Product
var modifier: Double
var date: Date
}
class Cart: ObservableObject {
#Published var orders = [Order]()
static let saveKey = "SavedData"
init() {
if let data = UserDefaults.standard.data(forKey: Self.saveKey) {
if let decoded = try? JSONDecoder().decode([Order].self, from: data) {
self.orders = decoded
}
} else {
self.orders = []
}
}
// save order
func save() {
if let encoded = try? JSONEncoder().encode(self.orders) {
UserDefaults.standard.set(encoded, forKey: Self.saveKey)
}
}
// add to order
func add(order: Order) {
self.orders.append(order)
print("product added to cart")
save()
}
// remove from order
func remove(order: Order) {
if let index = orders.firstIndex(of: order) {
orders.remove(at: index)
}
}
}
I made a View to apply the amount of any special product.
import SwiftUI
struct AmountView: View {
#EnvironmentObject var cart: Cart
#State private var textInput = ""
#State private var orderFinished = false
var product: Product
func StringDoubleConverter(text: String) -> String {
return String(format: "%.2f", Double(textInput.replacingOccurrences(of: ",", with: ".")) ?? 0)
}
var body: some View {
VStack {
Form {
Section(header: Text("Mengenangabe")) {
// input for the amount
AmountInputView(textInput: $textInput)
if !orderFinished {
Button("Hinzufügen", action: {
orderFinished = true
hideKeyboard()
// add product to the cart
self.cart.add(order: Order(product: product, modifier: Double(StringDoubleConverter(text: textInput))!, date: Date()))
})
.disabled(textInput == "")
.animation(.default)
} else {
Text("Wurde zum Logbuch hinzugefügt")
.foregroundColor(.blue)
}
}
productNutritionCollectionView(product: product, modifier: Double(StringDoubleConverter(text: textInput))!)
}
}
}
}
struct AmountView_Previews: PreviewProvider {
static var previews: some View {
AmountView(product: Product.exampleProduct).environmentObject(Cart())
}
}
Then, I want to display all products in the order in a logbook view using a Form and a ForEach lope.
struct LogbookView: View {
func deleteProducts(at offsets: IndexSet) {
cart.orders.remove(atOffsets: offsets)
cart.save()
}
#EnvironmentObject var cart: Cart
#State private var date = Date()
var body: some View {
NavigationView {
Form {
Section(header: Text("List")) {
ForEach(cart.orders) { order in
Text(order.product.name)
}
.onDelete(perform: { indexSet in
deleteProducts(at: indexSet)
})
}
}
.navigationBarTitle(Text("Logbuch"), displayMode: .automatic)
.navigationBarItems(trailing: DateView(date: $date))
}
}
}
struct LogbookView_Previews: PreviewProvider {
static var previews: some View {
LogbookView().environmentObject(Cart())
}
}
I'm using a AppTab View to navigate the app. Therefore, I changed the AppTab View in the main Struct to the default View with an environment object of Cart.
#main
struct KalorientrackerApp: App {
var body: some Scene {
WindowGroup {
AppTabView().environmentObject(Cart())
}
}
}
struct KalorientrackerApp_Previews: PreviewProvider {
static var previews: some View {
Text("Hello, World!")
}
}
I'm opening my AmountView using a .sheet
struct ProductDetailView: View {
#State private var showAmountView = false
let product: Product
var body: some View {
VStack {
// placeholder Image
Image(product.fullImage)
.clipShape(Circle())
.padding(.top, 5)
Spacer()
Form {
productNutritionCollectionView(product: product, modifier: 100)
}
}
// Titel for Navigation bar
.navigationBarTitle(Text(product.name), displayMode: .inline)
// Button to go to amount view
.navigationBarItems(trailing: Button(action: {
self.showAmountView = true
}, label: {
Image(systemName: "plus.circle")
.padding(.leading, 20)
}).sheet(isPresented: $showAmountView, content: {
AmountView(product: product).environmentObject(Cart())
}))
}
}
struct ProductDetailView_Previews: PreviewProvider {
static var previews: some View {
ProductDetailView(product: Product.exampleProduct) }
}
I already found some other discussions, but they didn't worked for me.
I'm using Xcode 12 beta 6 and iOS14 beta 6
I found the bug myself. The problem was, that I committed explicit an .environmentObject in my .sheet action.
AmountView(product: product).environmentObject(Cart())
I removed .environmentObject(Cart()) from the .sheet action. Now it's working.
Thinking this caused the bug because I'm using the .environmentObject(Cart()) operator in the main View of my project.

How can I use a variable set in one View in the PreviewProvider for that View?

I have a variable named homesList that I set in the completion handler of a function call to the server. However, homesList is not accessible in the PreviewProvider. How can I access homesList in the PreviewProvider?
struct HomeRow: View {
var home: Home
#State private var homesList: [Home]
var body: some View {
HStack {
Text(home.homeName)
Spacer()
}
.onAppear {
retrieveHomes(completionHandler: { (json) in
self.homesList = json
})
}
}
}
struct HomeRow_Previews: PreviewProvider {
static var previews: some View {
Group {
HomeRow(home: homesList[0])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}
Separating model management into view model and using dependency injection give possibility to mock view model for preview.
Here is a demo of possible approach. Tested with Xcode 12 / iOS 14
struct Home { // just replicated for test
var homeName: String
}
class HomesViewModel: ObservableObject {
#Published var homesList: [Home]
init(homes: [Home] = []) { // default container
self.homesList = homes
}
func fetchHomes() {
guard homesList.isEmpty else { return }
retrieveHomes(completionHandler: { (json) in // load if needed
DispatchQueue.main.async {
self.homesList = json // should be set on main queue
}
})
}
}
struct HomeRow: View {
var home: Home
#ObservedObject var vm: HomesViewModel
var body: some View {
HStack {
Text(home.homeName)
Spacer()
}
.onAppear {
self.vm.fetchHomes()
}
}
}
struct HomeRow_Previews: PreviewProvider {
static var previews: some View {
// prepare mock model with predefined mock homes
let mockModel = HomesViewModel(homes: [Home(homeName: "Mock1"), Home(homeName: "Mock2")])
return Group {
// inject test model via init
HomeRow(home: mockModel.homesList[0], vm: mockModel)
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

Making a combine passthrough publisher less global

Swift 5, iOS 13
I want to use passthroughSubject publisher; but I my gut tells me its a global variable and as such very poor practice. How can make this global variable less global, while still being usable. Here's some code to show what I talking about.
I know there are a dozen other ways to do this, but I wanted to create some simple code to illustrate the issue.
import SwiftUI
import Combine
let switcher = PassthroughSubject<Void,Never>()
struct SwiftUIViewF: View {
#State var nextPage = false
var body: some View {
VStack {
Text("Switcher")
.onReceive(switcher) { (_) in
self.nextPage.toggle()
}
if nextPage {
Page1ViewF()
} else {
Page2ViewF()
}
}
}
}
struct Page1ViewF: View {
var body: some View {
Text("Page 1")
.onTapGesture {
switcher.send()
}
}
}
struct Page2ViewF: View {
var body: some View {
Text("Page 2")
.onTapGesture {
switcher.send()
}
}
}
struct SwiftUIViewF_Previews: PreviewProvider {
static var previews: some View {
SwiftUIViewF()
}
}
Here is possible solution - to hold it in parent and inject into child views:
struct SwiftUIViewF: View {
let switcher = PassthroughSubject<Void,Never>()
#State var nextPage = false
var body: some View {
VStack {
Text("Switcher")
.onReceive(switcher) { (_) in
self.nextPage.toggle()
}
if nextPage {
Page1ViewF(switcher: switcher)
} else {
Page2ViewF(switcher: switcher)
}
}
}
}
struct Page1ViewF: View {
let switcher: PassthroughSubject<Void,Never>
var body: some View {
Text("Page 1")
.onTapGesture {
self.switcher.send()
}
}
}
struct Page2ViewF: View {
let switcher: PassthroughSubject<Void,Never>
var body: some View {
Text("Page 2")
.onTapGesture {
self.switcher.send()
}
}
}
An example using #EnvironmentObject.
Let SDK take care of observing / passing things for you, rather than setting up yourself.
Especially when your usage is a simple toggle.
import SwiftUI
import Combine
final class EnvState: ObservableObject { #Published var nextPage = false }
struct SwiftUIViewF: View {
#EnvironmentObject var env: EnvState
var body: some View {
VStack {
Text("Switcher")
if env.nextPage {
Page1ViewF()
} else {
Page2ViewF()
}
}
}
}
struct Page1ViewF: View {
#EnvironmentObject var env: EnvState
var body: some View {
Text("Page 1")
.onTapGesture {
env.nextPage.toggle()
}
}
}
struct Page2ViewF: View {
#EnvironmentObject var env: EnvState
var body: some View {
Text("Page 2")
.onTapGesture {
env.nextPage.toggle()
}
}
}
struct SwiftUIViewF_Previews: PreviewProvider {
static var previews: some View {
SwiftUIViewF().environmentObject(EnvState())
}
}

Resources