SwiftUI: modal Sheet hides ProgressView - ios

I'm trying to have a system-wide progress bar in my SwiftUI application, so I defined this view (be aware: I'm targeting iOS 13+):
import SwiftUI
struct LoadingView<Content>: View where Content: View {
#Binding var isShowing: Bool
var content: () -> Content
var body: some View {
GeometryReader { _ in
ZStack {
self.content()
if self.isShowing {
VStack {
ActivityIndicator()
Text("Loading...")
}
}
}
}
}
}
struct ActivityIndicator: UIViewRepresentable {
typealias UIView = UIActivityIndicatorView
fileprivate var configuration = { (_: UIView) in }
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView { UIView() }
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {
uiView.startAnimating()
configuration(uiView)
}
}
and is used in ContenView.swift like this
import SwiftUI
struct ContentView: View {
#EnvironmentObject var myViewModel: MyViewModel
var body: some View {
let isLoading = Binding<Bool>(
get: { self.myViewModel.isLoading },
set: { _ in }
)
LoadingView(isShowing: isLoading) {
NavigationView {
Home()
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
where MyViewModel is a pretty standard ViewModel with a #Published var for isLoading, and Home() is the main entry point for the app.
Whenever some action that trigger isLoading in MyViewModel is done, the progress bar is shown (and hidden) correctly.
But, if I present a .sheet either by one of the view inside the NavigationView, or using the ContentView itself, no matter what it hides the progress bar.
Even if I use the builtin 14+ ProgressView the problem persists.
.Zindex() does not help either.
Any way to have that view always on top when showed, no matter what .sheet, .alert or any overlay view is available on SwiftUI is present on the screen?
Thanks in advance!

As already written in the comments, a modal view will be shown on top of any other view. A modal view is meant to establish a Computer-Human communication, or dialog (thus modal views frequently will be named "Dialog").
The observation, that a sheet (modal view) covers the loading indicator is expected behaviour.
But, IMO the issue described in the question and refined in the comments, can be solved nicely without breaking the behaviour of the modal views:
When you want to show data, that is not yet complete or even completely absent, you may show a "blank" screen, and in additions to this, let the view model generate a view state that says, that the view should show an "Input Sheet".
So initially, the user sees an input form over a blank screen.
Once the user made the input and submits the form (which will be handled in the View Model) the input sheet disappears (controlled by the View State generated by the View Model), and reveals the "blank" view underneath it.
So, the View Model could now present another sheet, or it realises that the input is complete.
Once the input is complete, the view model loads data and since this may take a while, it reflects this in the View State accordingly, for example using a "loading" state or flag. The view renders this accordingly, which is a loading indicator above the "blank" view.
When the view model receives data, it clears the loading state and sets the view state accordingly, passing through the data as well.
The view now renders the data view.
If the loading task failed, the view model composes a view state where the content is "absent" and with an error info.
Again the view renders this, possibly showing an alert with the message above a "blank" view, since there is still no data.
Ensure, the user can dismiss the error alert and the view model handles it by removing the "modal error" state, but the content is still "absent".
Now, the user is starring at a blank view. You may embed an error message here, or even add a "Retry" button. In any case, ensure the user can navigate away from that screen.
And so on. ;)

Related

Is there no way to detect when a SwiftUI view is dismissed?

I have an app that is built using a NavigationSplitView with a menu on the left and a map on the right. The left view controls the state of the map depending on what view is currently shown in the menu. Previously I saved my own routing state model for the navigation when NavigationLinks where activated using tags and selection. This made it possible to know the exact state of the apps routing at all times. With the new NavigationStack, we have to use NavigationPath which can not be monitored since the internal values are private.
Another option we had previously for knowing when a view was dismissed was to create a StateObject for the view when the view was created, then it will be deallocated as the view is dismissed. However that won't work in NavigationStack since the new .navigationDestination is called multiple times like any type of view rendering, making the StateObject allocate and deallocate just as many times.
And yes, I know about .onAppear and .onDisappear. However, these events are irrelevant in this situation since they can be called multiple times during the views lifecycle e.g. when another view is presented on top of the current view etc.
Is it possible to detect when a view truly disappears (is dismissed) in SwiftUI?
This isn't an answer to how to detect when a screen disappears, but rather a solution to the first part of your problem.
With a NavigationStack, you don't have to use a NavigationPath object as the path.
The initialiser is:
init(path: Binding<Data>, #ViewBuilder root: () -> Root) where Data : MutableCollection, Data : RandomAccessCollection, Data : RangeReplaceableCollection, Data.Element : Hashable
so path can be a Binding of any array who's elements are Hashable. e.g.
struct ContentView: View {
enum Routing: Hashable {
case screen1, screen2(String)
}
#State private var path: [Routing] = []
var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink("Show screen 1", value: Routing.screen1)
NavigationLink("Show screen 2", value: Routing.screen2("Fred"))
}
.navigationDestination(for: Routing.self) { screen in
switch screen {
case .screen1:
Text("This is screen 1")
case let .screen2(name):
Text("This is screen 2 - name: \(name)")
}
}
}
.onChange(of: path) { newValue in
path.forEach { screen in
print(screen)
}
}
}
}
As your path is not an opaque object you can use that to determine your app's current state.

SwiftUI send action from a page to the PageViewController

I have set up a PageViewController in SwiftUI, following the known Tutorial Interfacing with UIKit, with UIViewControllerRepresentable etc.
My array of controllers consists of simple SwiftUI views. I pass a simple IntroPage struct to supply the content. The views are nested, as is good SwiftUI practice, thus:
PageView
- IntroScreen // part of the pages array
- VStack
- Text
- Image
- ButtonView // a separate view struct
- HStack
- ForEach // iterating through the buttons in my IntroPage Object
- Button
PageControl
Now I want to include some buttons on these views. They should be configurable via my IntroPage struct. One of them advances to the next page in the PageViewController, another tells the PageViewController to add more pages first, another button dismisses the entire PageViewController.
I cannot figure out, how get access to these methods in the PageViewController, where to implement them (the instance view? the coordinator of the PageViewController?) and how to reach the objects I need (e.g. the currentPage Binding variable of the PageViewController).
For example, I have implemented a forward() function in the PageViewController's coordinator:
func forward() {
if parent.currentPage < parent.controllers.count - 1 {
parent.currentPage += 1
}
}
...which works fine, with animations and all, if I add a button right beside the PageView on my final View. But I still cannot call this from the button contained in the child view.
Any ideas?
EDIT: Upon request, here is a situation in the ButtonView.
struct IntroButtonView: View {
var page: IntroPage
var body: some View {
HStack() {
Button(action:dismiss) {
Text("LocalizedButtonTitle")
}
// ... more buttons, based on certain conditions
}
}
func dismiss() {
// how to dismiss the modally presented controller ?
}
func next() {
// how to advance to the next page
}
func expand() {
// how to add more pages to the pages array
}
}
Or maybe I am completely wrong and still think in terms of "events" rather than "declaration"...
OK, I figured it out. Not intuitive at first, I have to say. Coming from traditional event based programming, it's quite a different way of thinking
I used a #State variable in the main instance of the view.
I used #Binding variables to deal with the state both upstream (ViewControllers, Controls) and downstream (subviews). So, for example, I used a variable to tell the dataSource of the UIPageViewController if or not to return a view controller before/after the current one.
For the dismissing the modally presented controller I used
#Environment(\.presentationMode) var presentationMode
...
func dismiss() {
self.presentationMode.wrapptedValue.dismiss()
}
Similarly,
...
#Binding var currentPage: int
...
Button(action: next) { Text("Next Page") }
...
...
func next() {
currentPage += 1
}
There were a few caveats in deciding how to nest the views and what variables to pick for the bindings, but it is clear to me now. The biggest problem was ultimately where the "source of truth" should be anchored. It turned out, right in the "middle", i.e. below the controller and above the particular views.
Hope this is useful for others looking for something similar.

SwiftUI Landmarks App Tutorial Screen Navigates Back When Toggle Favorite

I'm following this SwiftUI tutorial and downloaded the project files.
I built and ran the complete project without any modifications. In the app, if I:
Toggle "Show Favorites Only" on in the list view
Tap into the "Turtle Rock" or "Chilkoot Trail" detail view
In the detail view, I toggle the favorite button (a yellow star icon)
The screen will jump back to the list view by itself.
But if I tap into the detail view of the last item ("St. Mary Lake") in the list view, I can toggle the yellow star button on and off and still stay in the same detail view.
Can anyone explain this behavior? What do I need to do to stay in the detail view without being forced to navigate back to the list view?
Well, actually it is SwiftUI defect, the View being out of view hierarchy must not be refreshed (ie. body called) - it should be updated right after next appearance. (I submitted feedback #FB7659875, and recommend to do the same for everyone affected - this is the case when duplicates are better)
Meanwhile, below is possible temporary workaround (however it will continue work even after Apple fix the issue, so it is safe). The idea is to use local view state model as intermediate between view and published property and make it updated only when view is visible.
Provided only corrected view to be replaced in mentioned project.
Tested with Xcode 11.4 / iOS 13.4 - no unexpected "jump back"
struct LandmarkList: View {
#EnvironmentObject private var userData: UserData
#State private var landmarks = [Landmark]() // local model
#State private var isVisible = false // own visibility state
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}
ForEach(landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark)
.environmentObject(self.userData)
) {
LandmarkRow(landmark: landmark)
}
}
}
}
.onReceive(userData.$landmarks) { array in // observe external model
if self.isVisible {
self.landmarks = array // update local only if visible
}
}
.onAppear {
self.isVisible = true // track own state
self.landmarks = self.userData.landmarks
}
.onDisappear { self.isVisible = false } // track own state
.navigationBarTitle(Text("Landmarks"))
}
}
}
this happens because in the "main" list you toggled to "show only favorites". then you change in the detail the favorites (so it is no favorite landmark anymore) and because in swiftui the source of truth was changed (favorite) this item was removed from the main list and so it cannot be shown in the detail anymore because it is no member of the main list anymore, so the detail view just navigates back and show the favorite items only.

SwiftUI: NavigationLink pops immediately if used within ForEach

I'm using a NavigationLink inside of a ForEach in a List to build a basic list of buttons each leading to a separate detail screen.
When I tap on any of the list cells, it transitions to the detail view of that cell but then immediately pops back to the main menu screen.
Not using the ForEach helps to avoid this behavior, but not desired.
Here is the relevant code:
struct MainMenuView: View {
...
private let menuItems: [MainMenuItem] = [
MainMenuItem(type: .type1),
MainMenuItem(type: .type2),
MainMenuItem(type: .typeN),
]
var body: some View {
List {
ForEach(menuItems) { item in
NavigationLink(destination: self.destination(item.destination)) {
MainMenuCell(menuItem: item)
}
}
}
}
// Constructs destination views for the navigation link
private func destination(_ destination: ScreenDestination) -> AnyView {
switch destination {
case .type1:
return factory.makeType1Screen()
case .type2:
return factory.makeType2Screen()
case .typeN:
return factory.makeTypeNScreen()
}
}
If you have a #State, #Binding or #ObservedObject in MainMenuView, the body itself is regenerated (menuItems get computed again) which causes the NavigationLink to invalidate (actually the id change does that). So you must not modify the menuItems arrays id-s from the detail view.
If they are generated every time consider setting a constant id or store in a non modifying part, like in a viewmodel.
Maybe I found the reason of this bug...
if you use iOS 15 (not found iOS 14),
and you write the code NavigationLink to go to same View in different locations in your projects, then this bug appear.
So I simply made another View that has different destination View name but the same contents... then it works..
you can try....
sorry for my poor English...

What's the equivalent of the UISplitViewController in SwiftUI

I need to implement an UI which close to the default Mail app in iPad and iPhone.
The App has two sections, typically, the master view will be displayed on the left side and detail view will be displayed in the right side in iPad.
In the phone, the master view will be displayed on whole screen, the detail view can be pushed as second screen.
How to implement it in the new SwiftUI
There is not really a SplitView in SwiftUI, but what you describe is automatically accomplished when you use the following code:
import SwiftUI
struct MyView: View {
var body: some View {
NavigationView {
// The first View inside the NavigationView is the Master
List(1 ... 5, id: \.self) { x in
NavigationLink(destination: SecondView(detail: x)) {
Text("Master\nYou can display a list for example")
}
}
.navigationBarTitle("Master")
// The second View is the Detail
Text("Detail placeholder\nHere you could display something when nothing from the list is selected")
.navigationBarTitle("Detail")
}
}
}
struct SecondView: View {
var detail: Int
var body: some View {
Text("Now you are seeing the real detail View! This is detail \(detail)")
}
}
This is also why the .navigationBarTitle() modifier should be applied on the view inside the NavigationView, instead of on the NavigationView itself.
Bonus: if you don't like the splitView navigationViewStyle, you can apply the .navigationViewStyle(StackNavigationViewStyle()) modifier on the NavigationView.
Edit: I discovered that the NavigationLink has an isDetailLink(Bool) modifier. The default value appears to be true, because by default the "destination" is presented in the detail view (on the right). But when you set this modifier to false, the destination is presented as a master (on the left).

Resources