Swift UI show modal sheet with tab bar visible - swiftui

Is there anyway to keep the tab bar showing while presenting a modal / sheet view?
Here is a minimal failing example.
import SwiftUI
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
Button("Press to dismiss") {
dismiss()
}
.padding()
}
}
struct Tab1: View {
#State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
SheetView()
}
}
}
struct MainView: View {
var body: some View {
TabView {
Tab1()
.tabItem {
Label("Tab 1", systemImage: "heart")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}

Thanks for answering my question in the comments.
Unfortunately the standard means of presenting views in SwiftUI is that they are truly modal – they capture the whole interaction context for the current scene, and you can’t interact with anything else until the modal is dismissed.
This is also the case for iPadOS. Even though a modal presented with .sheet on an iPad allows much more of the underlying view to be visible, you can’t interact with it until the sheet disappears. You can interact with different parts of the app by running two scenes side-by-side in split screen mode, but each half is a separate scene and any presented sheets are modal for that scene.
If you want one tab to optionally present a view over its usual content but still allow access to the tab view and its other tabs, that’s not a modal context and SwiftUI’s built-in sheet won’t work. You will have to implement something yourself - but I think that’s doable.
Rather than using .sheet, you could optionally add an overlay to your Tab1 view, using the same boolean state variable showingSheet. In this approach, the default dismiss environment variable won’t be available, so passing in the state variable as a binding value would be an alternative:
var body: some View
<main display>
.overlay(showingSheet ? Sheet1(presented: $showingSheet) : EmptyView())
You might also find that a ZStack works better than .overlay depending on what the contents of the tab view actually are.
You’ll definitely have a lot more structural work to do to make this work, but I hope you can see that it’s possible.

Related

Swiftui: How to Close one Tab in a TabView?

I'm learning SwiftUI and having trouble with closing Each Tab View Element.
My App shows photos from user's album by TabView with pageViewStyle one by one.
And What I want to make is user can click save button in each view, and when button is clicked, save that photo and close only that view while other photos are still displayed. So unless all photos are saved or discarded, if user clicks save button, TabView should automatically move to another one.
However, I don't know how to close only one Tab Element. I've tried to use dismiss() and dynamically changing vm.images element. Latter one actually works, but it displays awkward movement and it also requires quite messy code. How could I solve this issue?
Here is my code.
TabView {
ForEach(vm.images, id: \.self) { image in
TestView(image: image)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
struct TestView: View {
#ObservedObject var vm: TestviewModel
...
var body: some View {
VStack(spacing: 10) {
Image(...)
Spacer()
Button {
...
} label: {
Text("Save")
}
}
You need actually to remove saved image from the viewModel container, and UI will be updated automatically
literally
Button {
vm.images.removeAll { $0.id == image.id } // << here !!
} label: {
Text("Save")
}
You need to use the selection initializer of TabView in order to control what it displays. So replace TabView with:
TabView(selection: $selection)
Than add a new property: #State var selection: YourIdType = someDefaultValue, and in the Button action you set selection to whatever you want to display.
Also add .tag(TheIdTheViewWillUse) remember that whatever Id you use must be the same as your selection variable. I recommend you use Int for the simple use.

Does TabView miss navigation clicks while redrawing?

I have a vanilla TabView, an #ObservedObject, and a background task running which periodically updates the #Published field of the #ObservedObject.
I've noticed that when the background task runs more frequently, and generates more changes to the #ObservedObject, the navigation tabs on the TabView become less responsive - they 'miss' taps. If I tap two or three times, I can normally change the view. The more frequent the background updates, the more often taps are 'missed'.
So I'm wondering if the TabView somehow fails to notice taps while it is redrawing, or something like that?
Any insights?
import Foundation
import SwiftUI
struct ContentView2: View {
enum Tabs {
case main
case audio
case preferences
}
#State var tab = Tabs.preferences
#ObservedObject var appState = Globals.appState
var body: some View {
TabView(selection: $tab) {
MainView()
.tabItem {
Image(systemName: "video.fill")
Text("Main")
}.tag(Tabs.main)
AudioControlView()
.tabItem {
Image(systemName: "speaker.fill")
Text("Audio")
}.tag(Tabs.audio)
PreferenceView()
.tabItem {
Image(systemName: "gear")
Text("Prefs")
}.tag(Tabs.preferences)
}
}
}
Found the problem...
The TabView doesn't need #ObservedObject var appState = Globals.appState because the TabView itself doesn't depend on the #Published fields.
Instead, the individual tabs (MainView() etc. ) need to observe.
So what was happening was that the TabView was being unnecessarily recomputed and redrawn for every change. Removing this line fixed everything.

How do I create a simple app with a side bar in SwiftUI?

I've had my app inside a NavigationView using the StackNavigationViewStyle style for some time with no problems. Recently I wanted to add a sidebar to it though, so I thought I should try using the DoubleColumnNavigationViewStyle style for this. At the moment I can kind of make it work but it has some quirks:
If I am in a subview, and I try to slide back into its parent view, sliding back always brings the side bar into view instead of taking me back into the parent view which is what I would expect. Now matter how deep into my view hierarchy I am. (If you use the default Notes app and select View as a Gallery, that is exactly the way I expect my app to work like).
Much less important but annoying nonetheless is that if I press the back button, the nice animations of the sliding < back buttons into/out of view I got when I used StackNavigationViewStyle no longer happen. The buttons work fine but the animations are much worse now.
Here is a sample minimum app and a video to show what I mean:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
Text("Sidebar")
MainView()
}
}
}
struct MainView: View {
var body: some View {
Text("Main View")
NavigationLink(destination: Text("Sub View")) {
Text("Go to Sub View")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Thanks!

NavigationLink doesn't fire after FullScreenCover is dismissed

I have a button in a view (inside a NavigationView) that opens a full screen cover - a loading screen while some data is processing. When the cover is dismissed, I want to automatically route to the next view programmatically. I'm using a NavigationLink with a tag and selection binding, and the binding value updates when the cover is dismissed, but the routing doesn't happen unless I tap that same "open modal" button again.
import SwiftUI
struct OpenerView: View {
#EnvironmentObject var viewModel: OpenerViewModel
#State private var selection: Int? = nil
#State private var presentLoadingScreen = false
var body: some View {
VStack {
NavigationLink(destination: SecondScreen(), tag: 1, selection: $selection) { EmptyView() }
Button(action: {
viewModel.frequency = 0
self.presentLoadingScreen.toggle()
}, label: {
Text("Open cover")
}).buttonStyle(PlainButtonStyle())
}
.navigationBarTitle("Nav title", displayMode: .inline)
.fullScreenCover(isPresented: $presentLoadingScreen, onDismiss: {
self.selection = 1
}, content: ModalView.init)
}
}
struct ModalView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Text("Howdy")
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
presentationMode.wrappedValue.dismiss()
}
}
}
}
The first time I hit the Button, the cover opens. Inside the cover is just a DispatchQueue.main.asyncAfter which dismisses it after 2 seconds. When it's dismissed, the onDismiss fires, but then I have to hit the button again to route to SecondScreen.
Any ideas?
Edit: Added the modal's View
I got it working with some changes to the code, and I'm sharing here along with what I think is happening.
I believe the problem is a race condition using a #State boolean to toggle the cover and the navigation. When the cover is being dismissed, my main OpenerView is being recreated - to be expected with state changes. Because of this, I try to set the #State var selection to trigger the navigation change, but before it can do so, the view is recreated with selection = nil.
There seem to be two ways to solve it in my case:
Move the cover boolean to my view model - this worked, but I didn't want it there because it only applied to this view and it's a shared view model for this user flow. Plus, when the modal is dismissed, you see the current OpenerView for a brief flash and then get routed to the SecondScreen.
Keep the cover boolean in #State, but trigger the navigation change in the button immediately after setting the boolean to open the modal. This worked better for my use case because the modal opens, and when it closes, the user is already on the next screen.
I had a similar problem where I was trying to draw a view after dismissing a fullScreenCover. I kept getting an error that said that the view had been deallocated since it was trying to draw to the fullScreenCover.
I used Joe's hints above to make this work. Specifically:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
viewToShow()
}
I had previously tried onChange, onDisappear, onAppear - but none of those fit the use case I needed.

Is it possible to make a modal non-dismissible in SwiftUI?

I am creating an App where the login / register part is inside a modal, which is shown if the user is not logged in.
The problem is, that the user can dismiss the modal by swiping it down...
Is it possible to prevent this?
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) { // This needs to be non-dismissible
LoginRegister()
}
}
Second example:
I am using a modal to ask for information. The user should not be able to quit this process except by dismissing the modal with save button. The user has to input information before the button works. Unfortunately the modal can be dismissed by swiping it down.
Is it possible to prevent this?
iOS 15 and later:
Use .interactiveDismissDisabled(true) on the sheet, that's all.
Prev iOS 15:
You can try to do this by using a highPriorityGesture. Of course the blue Rectangle is only for demonstration but you would have to use a view which is covering the whole screen.
struct ModalViewNoClose : View {
#Environment(\.presentationMode) var presentationMode
let gesture = DragGesture()
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 300, height: 600)
.highPriorityGesture(gesture)
.overlay(
VStack{
Button("Close") {
self.presentationMode.value.dismiss()
}.accentColor(.white)
Text("Modal")
.highPriorityGesture(gesture)
TextField("as", text: .constant("sdf"))
.highPriorityGesture(gesture)
} .highPriorityGesture(gesture)
)
.border(Color.green)
}
}
This is a common problem and a "code smell"... well not really code but a "design pattern smell" anyway.
The problem is that you are making your login process part of the rest of the app.
Instead of presenting the LoginRegister over the App you should really be showing either App or LoginRegister.
i.e. you should have some state object like userLoggedIn: Bool or something and depending on that value you should show either App or LoginRegister.
Just don't have both in the view hierarchy at the same time. That way your user won't be able to dismiss the view.
If you dont mind using Introspect:
import Introspect
#available(iOS 13, *)
extension View {
/// A Boolean value indicating whether the view controller enforces a modal behavior.
///
/// The default value of this property is `false`. When you set it to `true`, UIKit ignores events
/// outside the view controller's bounds and prevents the interactive dismissal of the
/// view controller while it is onscreen.
public func isModalInPresentation(_ value: Bool) -> some View {
introspectViewController {
$0.isModalInPresentation = value
}
}
}
Usage:
.sheet {
VStack {
...
}.isModalInPresentation(true)
}
iOS 15+
Starting from iOS 15 you can use interactiveDismissDisabled.
You just need to attach it to the sheet:
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) {
LoginRegister()
.interactiveDismissDisabled(true)
}
}
Regarding your second example, you can pass a variable to control when the sheet is disabled:
.interactiveDismissDisabled(!isAllInformationProvided)
You can find more information in the documentation.
theoretically this may help you (I didn't tryed it)
private var isDisplayedBind: Binding<Bool>{ Binding(get: { true }, set: { _ = $0 }) }
and usage:
content
.sheet(isPresented: isDisplayedBind) { some sheet }