SwiftUI - .task view modifier is not being called when child view refreshes - swift

In my app, I have a parent view to display a user's profile with a child view to display the user's ratings. Both of these views have a .task modifier that fetches data from a server (userProfile data for the parent and reviews data for the child).
However, I'm running into an issue where if the parent view refreshes for any reason, the subview data resets and loses its state but the .task modifier doesn't fire.
Is this a bug? How can I force the child view to refresh when the parent view refreshes? Do I need to move the viewmodel out of the child view entirely and inject it?
I've reproduced the basic structure of the app below. When the button is clicked, the parent view state changes, causing the child view to reset and lose its state. However, the .task function isn't called.
struct ContentView: View {
var body: some View {
ParentView()
}
}
struct ParentView: View {
#State var parentViewNumber: Int?
func incrementParent () async {
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
if let parentViewNumber = parentViewNumber {
self.parentViewNumber = parentViewNumber + 1
}
else {
parentViewNumber = 1
}
}
var body: some View {
VStack {
Text("Parent View Number: \(parentViewNumber ?? 0)")
Button {
Task {
await incrementParent()
}
} label: {
Text("Increment Parent View")
}
if parentViewNumber != nil {
ChildView(parentViewNumber: parentViewNumber)
}
}
.task {
await incrementParent()
}
}
}
struct ChildView: View {
class ChildViewModel: ObservableObject {
#Published var childViewNumber: Int?
init() {
print ("ChildViewModel init called")
}
#MainActor
func incrementChildViewNubmer () async {
print ("incrementChildViewNubmer called")
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
if let childViewNumber = childViewNumber {
self.childViewNumber = childViewNumber + 1
}
else {
childViewNumber = 1
}
}
}
#ObservedObject var childViewModel: ChildViewModel
var parentViewNumber: Int?
init(parentViewNumber: Int?) {
print("ChildView init called")
self.parentViewNumber = parentViewNumber
//In the actual app, this ViewModel receives an Id field.
childViewModel = ChildViewModel()
}
var body: some View {
VStack {
Text("ChildView Number: \(childViewModel.childViewNumber ?? 0)")
Text("ParentView Number \(parentViewNumber ?? 0)")
}
.task {
await childViewModel.incrementChildViewNubmer()
}
}
}
In the above example, when the view initially loads, childViewNumber is set to 1 as expected. When the button is clicked, childViewNumber gets set back to nil, and doesn't get updated.

Related

Redirecting after task w/ Await completes

In a view, I want to wait for a series of async calls to finish loading, then redirect to another screen. Unfortunately, I see the code running in the back (The JSON data gets loaded) but once it completes it does not redirect to the new view.
Here is my view:
struct loadingView: View {
#ObservedObject var dataLoader: DataLoader = DataLoader()
#State var isLoaded: Bool = false
var body: some View {
VStack {
Text("Loading \(isLoaded)")
}
}
.task {
await self.dataloader.loadJSONData(isLoaded: $isLoaded)
MainScreen()
}
}
...and the DataLoader class:
#MainActor DataLoader: NSObject, ObservableObject {
func loadJSONData(isLoaded: Binding<Bool>) {
await doLoadData()
isLoaded.wrappedValue = True
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
"Redirecting" here doesn't really make sense. Do you really want the user to be able to navigate back to the loading screen? Perhaps you're thinking of this like a web page, but SwiftUI is nothing like that. What you really want to do is display one thing when loading, and a different thing when loaded. That's just if, not "redirection."
Instead, consider the following pattern. Create this kind of LoadingView (extracted from some personal code of mine):
struct LoadingView<Content: View, Model>: View {
enum LoadState {
case loading
case loaded(Model)
case error(Error)
}
#ViewBuilder let content: (Model) -> Content
let loader: () async throws -> Model
#State var loadState = LoadState.loading
var body: some View {
ZStack {
Color.white
switch loadState {
case .loading: Text("Loading")
case .loaded(let model): content(model)
case .error(let error): Text(verbatim: "Error: \(error)")
}
}
.task {
do {
loadState = .loaded(try await loader())
} catch {
loadState = .error(error)
}
}
}
}
It require no redirection. It just displays different things when in different states (obviously the Text view can be replaced by something more interesting).
Then to use this, embed it in another View. In my personal code, that includes a view like this:
struct DailyView: View {
var body: some View {
LoadingView() { model in
LoadedDailyView(model: model)
} loader: {
try await DailyModel()
}
}
}
Then LoadedDailyView is the "real" view. It is handled a fully populated model that is created by DailyModel.init (a throwing, async init).
You could try this approach, using NavigationStack and NavigationPath to Redirecting after task w/ Await completes.
Here is the code I use to test my answer:
struct ContentView: View {
var body: some View {
loadingView()
}
}
#MainActor
class DataLoader: NSObject, ObservableObject {
func loadJSONData() async {
await doLoadData()
// for testing, wait for 1 second
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State private var navPath = NavigationPath()
var body: some View {
NavigationStack(path: $navPath) {
VStack (spacing: 44) {
Text("Loading....")
}
.navigationDestination(for: Bool.self) { _ in
MainScreen()
}
}
.task {
await dataLoader.loadJSONData()
navPath.append(true)
}
}
}
struct MainScreen: View {
var body: some View {
Text("---> MainScreen here <---")
}
}
If you need ios 15 or earlier, then use NavigationView:
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State var isLoaded: Bool?
var body: some View {
NavigationView {
VStack {
Text(isLoaded == nil ? "Loading..." : "Finished loading")
NavigationLink("", destination: MainScreen(), tag: true, selection: $isLoaded)
}
}.navigationViewStyle(.stack)
.task {
await dataLoader.loadJSONData()
isLoaded = true
}
}
}
If your loadingView has the only purpose of showing the "loading" message, then
display the MainScreen after the data is loaded, you could use the following approach using a simple swicth:
struct loadingView: View {
#StateObject var dataLoader = DataLoader()
#State private var isLoaded = false
var body: some View {
VStack {
if isLoaded {
MainScreen()
} else {
ProgressView("Loading")
}
}
.task {
await dataLoader.loadJSONData()
isLoaded = true
}
}
}
Use #StateObject instead of #ObservedObject. Use #Published instead of trying to pass a binding to the object (that is a mistake because a binding is just a pair of get and set closures that will expire if LoadingView is re-init), use Group with an if to conditionally show a View e.g.
struct LoadingView: View {
#StateObject var dataLoader: DataLoader = DataLoader()
var body: some View {
Group {
if dataLoader.isLoaded {
LoadedView(data: dataLoader.data)
} else {
Text("Loading...")
}
}
.task {
await dataloader.loadJSONData()
}
}
The DataLoader should not be #MainActor because you want it to run on a background thread. Use #MainActor instead on a sub-task once the async work has finished e.g.
class DataLoader: ObservableObject {
#Published var isLoaded = false
#Published var data: [Data] = []
func loadJSONData async {
let d = await doLoadData()
Task { #MainActor in
isLoaded = true
data = d
}
}
func doLoadData() async {
/* do data load */
/* This code works */
}
}
This pattern is shown in Apple's tutorial here, PandaCollectionFetcher.swift copied below:
import SwiftUI
class PandaCollectionFetcher: ObservableObject {
#Published var imageData = PandaCollection(sample: [Panda.defaultPanda])
#Published var currentPanda = Panda.defaultPanda
let urlString = "http://playgrounds-cdn.apple.com/assets/pandaData.json"
enum FetchError: Error {
case badRequest
case badJSON
}
func fetchData() async
throws {
guard let url = URL(string: urlString) else { return }
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badRequest }
Task { #MainActor in
imageData = try JSONDecoder().decode(PandaCollection.self, from: data)
}
}
}

How do I instantly load core data in a view after closing my popup?

The point of this app is to use core data to permanently add types of fruit to a list. I have two views: ContentView and SecondScreen. SecondScreen is a pop-up sheet. When I input a fruit and press 'save' in SecondScreen, I want to immediately update the list in ContentView to reflect the type of fruit that has just been added to core data as well as the other fruits which have previously been added to core data. My problem is that when I hit the 'save' button in SecondScreen, the new fruit is not immediately added to the list in ContentView. Instead, I have to restart the app to see the new fruit in the list.
Here is the class for my core data:
class CoreDataViewModel: ObservableObject {
let container: NSPersistentContainer
#Published var savedEntities: [FruitEntity] = []
init() {
container = NSPersistentContainer(name: "FruitsContainer")
container.loadPersistentStores { (description, error) in
if let error = error {
print("Error with coreData. \(error)")
}
}
fetchFruits()
}
func fetchFruits() {
let request = NSFetchRequest<FruitEntity>(entityName: "FruitEntity")
do {
savedEntities = try container.viewContext.fetch(request)
} catch let error {
print("Error fetching. \(error)")
}
}
func addFruit(text: String) {
let newFruit = FruitEntity(context: container.viewContext)
newFruit.name = text
saveData()
}
func saveData() {
do {
try container.viewContext.save()
fetchFruits()
} catch let error {
print("Error saving. \(error)")
}
}
}
Here is my ContentView struct:
struct ContentView: View {
//sheet variable
#State var showSheet: Bool = false
#StateObject var vm = CoreDataViewModel()
#State var refresh: Bool
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button(action: {
showSheet.toggle()
}, label: {
Text("Add Fruit")
})
List {
ForEach(vm.savedEntities) { entity in
Text(entity.name ?? "NO NAME")
}
}
}
.navigationTitle("Fruits")
.sheet(isPresented: $showSheet, content: {
SecondScreen(refresh: $refresh)
})
}
}
}
Here is my SecondScreen struct:
struct SecondScreen: View {
#Binding var refresh: Bool
#Environment(\.presentationMode) var presentationMode
#StateObject var vm = CoreDataViewModel()
#State var textFieldText: String = ""
var body: some View {
TextField("Add fruit here...", text: $textFieldText)
.font(.headline)
.padding(.horizontal)
Button(action: {
guard !textFieldText.isEmpty else { return }
vm.addFruit(text: textFieldText)
textFieldText = ""
presentationMode.wrappedValue.dismiss()
refresh.toggle()
}, label: {
Text("Save")
})
}
}
To try and solve this issue, I've created a #State boolean variable called 'refresh' in ContentView and bound it with the 'refresh' variable in SecondScreen. This variable is toggled when the user hits the 'save' button on SecondScreen, and I was thinking that maybe this would change the #State variable in ContentView and trigger ContentView to reload, but it doesn't work.
In your second screen , change
#StateObject var vm = CoreDataViewModel()
to
#ObservedObject var vm: CoreDataViewModel
then provide for the instances that compiler will ask for
hope it helps
You need to use #FetchRequest instead of #StateObject and NSFetchRequest. #FetchRequest will call body to update the Views when the fetch result changes.

Swift: Error converting type 'Binding<Subject>' when passing Observed object's property to child view

I want to load data from an API, then pass that data to several child views.
Here's a minimal example with one child view (DetailsView). I am getting this error:
Cannot convert value of type 'Binding<Subject>' to expected argument type 'BusinessDetails'
import Foundation
import SwiftUI
import Alamofire
struct BusinessView: View {
var shop: Business
class Observer : ObservableObject{
#Published public var shop = BusinessDetails()
#Published public var loading = false
init(){ shop = await getDetails(id: shop.id) }
func getDetails(id: String) async -> (BusinessDetails) {
let params = [
id: id
]
self.loading = true
self.shop = try await AF.request("https://api.com/details", parameters: params).serializingDecodable(BusinessDetails.self).value
self.loading = false
return self.shop
}
}
#StateObject var observed = Observer()
var body: some View {
if !observed.loading {
TabView {
DetailsView(shop: $observed.shop)
.tabItem {
Label("Details", systemImage: "")
}
}
}
}
}
This has worked before when the Observed object's property wasn't an object itself (like how the loading property doesn't cause an error).
When using async/await you should use the .task modifier and remove the object. The task will be started when the view appears, cancelled when it disappears and restarted when the id changes. This saves you a lot of effort trying to link async task lifecycle to object lifecycle. e.g.
struct BusinessView: View {
let shop: Business
#State var shopDetails = BusinessDetails()
#State var loading = false
var body: some View {
if loading {
Text("Loading")
}
else {
TabView {
DetailsView(shop: shopDetails)
.tabItem {
Label("Details", systemImage: "")
}
}
}
.task(id: shop.id) {
loading = true
shopDetails = await Self.getDetails(id: shop.id) // usually we have a try catch around this so we can show an error message
loading = false
}
}
// you can move this func somewhere else if you like
static func getDetails(id: String) async -> BusinessDetails{
let params = [
id: id
]
let result = try await AF.request("https://api.com/details", parameters: params).serializingDecodable(BusinessDetails.self).value
return result
}
}
}

SwiftUI: #Environment(\.presentationMode)'s dismiss not working in iOS14

I have a view that shows a sheet for filtering the items in a list. The view has this var:
struct JobsTab: View {
#State private var jobFilter: JobFilter = JobFilter()
var filter: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView($jobFilter, categoriesViewModel, jobsViewModel)
}
}
However, in the sheet, I'm trying the following and I can't make the view dismissed when clicking on the DONE button, only on the CANCEL button:
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var categoriesViewModel: CategoriesViewModel
#ObservedObject var jobsViewModel: JobsViewModel
let filterViewModel: FilterViewModel
#Binding var jobFilter: JobFilter
#State private var draft: JobFilter
#State var searchText = ""
init(_ jobFilter: Binding<JobFilter>, _ categoriesViewModel: CategoriesViewModel, _ jobsViewModel: JobsViewModel) {
_jobFilter = jobFilter
_draft = State(wrappedValue: jobFilter.wrappedValue)
self.categoriesViewModel = categoriesViewModel
self.jobsViewModel = jobsViewModel
self.filterViewModel = FilterViewModel()
}
...
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("FilterView.Button.Cancel.Text".capitalizedLocalization) {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("FilterView.Button.Done.Text".capitalizedLocalization) {
let request = Job.defaultRequest()
request.predicate = filterViewModel.buildPredicate(withJobFilterDraft: self.draft)
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(Job.publicationDate), ascending: false)]
jobsViewModel.filteredJobsFetchRequest = request
self.jobFilter = self.draft
presentationMode.wrappedValue.dismiss()
}
}
}
I have also tried with a #Binding like Paul says here but there's no luck.
Is there any workaround, or am I doing something wrong?
Thanks in advance!
EDIT: I've posted the properties of both views, because I think the problem comes from the the line in FilterView self.jobFilter = self.draft.
What I'm trying to do here is create a filter view, and the aforementioned line will be executed when the user presses the DONE button: I want to assign my binding jobFilter in the JobsTab the value of the FilterView source of truth (which is a #State) and probably, since I'm updating the binding jobFilter the FilterView is being shown again even though the $showFilter is false? I don't know to be honest.
EDIT2: I have also tried
``
if #available(iOS 15.0, *) {
let _ = Self._printChanges()
} else {
// Fallback on earlier versions
}
in both `FilterView` and its called `JobTabs` and in both, I get the same result: unchanged
According to your code, I assumed your FilterView() is not a sub view, but an independent view by its own.
Therefore, to make sure "presentationMode.wrappedValue.dismiss()" works, you don't need to create #Binding or #State variables outside the FilerView() for passing the data back and forth between different views. Just create one variable inside your FilterView() to make it works.
I don't have your full code, but I created a similar situation to your problem as below code:
import SwiftUI
struct Main: View {
#State private var showFilter = false
var body: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView()
}
}
}
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
NavigationView {
VStack {
Text("Filter View")
}.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("cancel")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("okay")
}
}
}
}
}
}
the parameter onDismiss is missing:
.sheet(isPresented: $showFilter, onDismiss: { isPresented = false }) { ... }
View model objects are usually a source of bugs because we don't use those in SwiftUI instead we use structs for speed and consistency. I recommend making an #State struct containing a bool property isSheetPresented and also the data the sheet needs. Add a mutating func and call it on the struct from the button event. Pass a binding to the struct into the sheet's view where you can call another mutating function to set the bool to false. Something like this:
struct SheetConfig {
var isPresented = false
var data: [String] = []
mutating func show(initialData: [String] ) {
isPresented = true
data = initialData
}
mutating func hide() {
isPresented = false
}
}
struct ContentView: View {
#State var config = SheetConfig()
var body: some View {
Button {
config.show(intialData: ...)
} label: {
}
.sheet(isPresented: $config.isPresented) {
FilterView($config)
}
Here is what I did to handle this issue.
Create a new protocol to avoid repeatations.
public protocol UIViewControllerHostedView where Self: View {
/// A method which should be triggered whenever dismiss is needed.
/// - Note: Since `presentationMode & isPresented` are not working for presented UIHostingControllers on lower iOS versions than 15. You must call, this method whenever you want to dismiss the presented SwiftUI.
func dismissHostedView(presentationMode: Binding<PresentationMode>)
}
public extension UIViewControllerHostedView {
func dismissHostedView(presentationMode: Binding<PresentationMode>) {
// Note: presentationMode & isPresented are not working for presented UIHostingControllers on lower iOS versions than 15.
if #available(iOS 15, *) {
presentationMode.wrappedValue.dismiss()
} else {
self.topViewController?.dismisOrPopBack()
}
}
}
Extend UIWindow and UIApplication
import UIKit
public extension UIWindow {
// Credits: - https://gist.github.com/matteodanelli/b8dcdfef39e3417ec7116a2830ff67cf
func visibleViewController() -> UIViewController? {
if let rootViewController: UIViewController = self.rootViewController {
return UIWindow.getVisibleViewControllerFrom(vc: rootViewController)
}
return nil
}
class func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
switch(vc){
case is UINavigationController:
let navigationController = vc as! UINavigationController
return UIWindow.getVisibleViewControllerFrom( vc: navigationController.visibleViewController!)
case is UITabBarController:
let tabBarController = vc as! UITabBarController
return UIWindow.getVisibleViewControllerFrom(vc: tabBarController.selectedViewController!)
default:
if let presentedViewController = vc.presentedViewController {
if let presentedViewController2 = presentedViewController.presentedViewController {
return UIWindow.getVisibleViewControllerFrom(vc: presentedViewController2)
}
else{
return vc;
}
}
else{
return vc;
}
}
}
}
#objc public extension UIApplication {
/// LCUIComponents: Returns the current visible top most window of the app.
var topWindow: UIWindow? {
return windows.first(where: { $0.isKeyWindow })
}
var topViewController: UIViewController? {
return topWindow?.visibleViewController()
}
}
Extend View to handle UIHostingController presented View`
public extension View {
weak var topViewController: UIViewController? {
UIApplication.shared.topViewController
}
}
Finally, use the helper method in your SwiftUI view as follows: -
struct YourView: View, UIViewControllerHostedView {
// MARK: - Properties
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body some View {
Button {
// Note: this part trigger a method in a created protocol above.
dismissHostedView(presentationMode: presentationMode)
} label: {
Text("Tap to dismiss")
}
}
In the sheet try adding this instead.
#Environment(\.dismiss) var dismiss // EnvironmentValue
Button("Some text") {
// Code
dismiss()
}

SwiftUI Navigation View goes back in hierarchy

I have a problem with Navigation View hierarchy.
All screens in my app use the same ViewModel.
When a screen inside navigation link updates the ViewModel (here it is called DataManager), the navigation view automatically goes back to the first screen, as if the "Back" button was pressed.
Here's what it looks like
I tried to shrink my code as much as I could
struct DataModel: Identifiable, Codable {
var name: String
var isPinned: Bool = false
var id: String = UUID().uuidString
}
class DataManager: ObservableObject {
#Published private(set) var allModules: [DataModel]
var pinnedModules: [DataModel] {
allModules.filter { $0.isPinned }
}
var otherModules: [DataModel] {
allModules.filter { !$0.isPinned }
}
func pinModule(id: String) {
if let moduleIndex = allModules.firstIndex(where: { $0.id == id }) {
allModules[moduleIndex].isPinned = true
}
}
func unpinModule(id: String) {
if let moduleIndex = allModules.firstIndex(where: { $0.id == id }) {
allModules[moduleIndex].isPinned = false
}
}
static let instance = DataManager()
fileprivate init() {
allModules =
[DataModel(name: "One"),
DataModel(name: "Two"),
DataModel(name: "Three"),
DataModel(name: "Four"),
DataModel(name: "Five")]
}
}
struct ModulesList: View {
#StateObject private var dataStorage = DataManager.instance
var body: some View {
NavigationView {
List {
Section("Pinned") {
ForEach(dataStorage.pinnedModules) { module in
ModulesListCell(module: module)
}
}
Section("Other") {
ForEach(dataStorage.otherModules) { module in
ModulesListCell(module: module)
}
}
}
}
}
fileprivate struct ModulesListCell: View {
let module: DataModel
var body: some View {
NavigationLink {
SingleModuleScreen(module: module)
} label: {
Text(module.name)
}
}
}
}
struct SingleModuleScreen: View {
#State var module: DataModel
#StateObject var dataStorage = DataManager.instance
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(module.name)
.font(.title)
Button {
dataStorage.pinModule(id: module.id)
} label: {
Text("Pin")
}
}
}
}
}
I can guess because when your dataStorage changed, the ModulesList will be redrawn, that cause all current ModulesListCell removed from memory.
Your cells are NavigationLink and when it destroyed, the navigation stack doesn't keep the screen that's being linked.
I would recommend to watch this wwdc https://developer.apple.com/videos/play/wwdc2021/10022/ and you will know how to manage your view identity properly when your data's changed.
When you tap Pin, your otherModules array is recreated and you do not have the view in Navigation Stack from where you navigated. Thus you are going back automatically, which is desired behaviour. So the solution is Don't destroy your array from where your NavigationLink is created. Make a temporary published array, load Other modules from that array and change the array onAppear like below:
As a workaround which is working in my end:
Add this line in DataManger:
#Published var tempOtherModules:[DataModel] = []
Change your ModulesList like below
struct ModulesList: View {
#StateObject private var dataStorage = DataManager.instance
var body: some View {
NavigationView {
List {
Section("Pinned") {
ForEach(dataStorage.pinnedModules) { module in
ModulesListCell(module: module)
}
}
Section("Other") {
ForEach(dataStorage.tempOtherModules) { module in
ModulesListCell(module: module)
}
}
}.onAppear {
dataStorage.tempOtherModules = dataStorage.otherModules
}
}
}
}