Additional safe area on NavigationView in SwiftUI - swiftui

I am building a SwiftUI app where I have an overlay that is conditionally shown across my entire application like this:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
.safeAreaInset(edge: .bottom) {
Group {
if myCondition {
EmptyView()
} else {
OverlayView()
}
}
}
}
}
}
I would expect this to adjust the safe area insets of the NavigationView and propagate it to any content view, so content is not stuck under the overlay. At least that's how additionalSafeAreaInsets in UIKit would behave. Unfortunately, it seems that SwiftUI ignores any safeAreaInsets() on a NavigationView (the overlay will show up, but safe area is not adjusted).
While I can use a GeometryReader to read the overlay size and then set safeAreaInsets() on ContentView, this will only work for ContentView - as soon as I navigate to the next view the safe area is gone.
Is there any nice way to get NavigationView to accept additional safe area insets, either by using safeAreaInsets() or by some other way?

So it seems NavigationView does not adjust its safe area inset when using .safeAreaInset. If this is intended or a bug is not clear to me. Anyway, I solved this for now like this (I wanted to use pure SwiftUI, using UIKit's additionalSafeAreaInsets might be an option to):
Main App File:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
.environmentObject(SafeAreaController.shared)
.safeAreaInset(edge: .bottom) {
OverlayView()
.frameReader(safeAreaController.updateAdditionalSafeArea)
}
}
}
}
class SafeAreaController: ObservableObject {
static let shared = SafeAreaController()
#Published private(set) var additionalSafeArea: CGRect = .zero
func updateAdditionalSafeArea(_ newValue: CGRect) {
if newValue != additionalSafeArea {
additionalSafeArea = newValue
}
}
}
struct FrameReader: ViewModifier {
let changeChandler: ((CGRect) -> Void)
init(_ changeChandler: #escaping (CGRect) -> Void) {
self.changeChandler = changeChandler
}
func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let newFrame = geometry.frame(in: .global)
changeChandler(newFrame)
}
return Color.clear
}
)
}
}
extension View {
func frameReader(_ changeHandler: #escaping (CGRect) -> Void) -> some View {
return modifier(FrameReader(changeHandler))
}
}
EVERY Content View that is pushed on your NavigationView:
struct ContentView: View {
#EnvironmentObject var safeAreaController: SafeAreaController
var body: some View {
YourContent()
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: safeAreaController.additionalSafeArea.height)
}
}
Why does it work?
In the main app file, a GeometryReader is used to read the size of the overlay created inside safeAreaInset(). The size is written to the shared SafeAreaController
The shared SafeAreaController is handed as an EnvironmentObject to every content view of our navigation
An invisible object is created as the .safeAreaInset of every content view with the height read from the SafeAreaController - this will basically create an invisible bottom safe area that is the same size as our overlay, thus making room for the overlay

struct ContentView: View {
var body: some View {
NavigationView {
ListView()
.navigationBarTitle("Test")
}
.safeAreaInset(edge: .bottom) {
HStack {
Spacer()
Text("My Overlay")
.padding()
Spacer()
}
.background(.ultraThinMaterial)
}
}
}
struct ListView: View {
var body: some View {
List(0..<30) { item in
NavigationLink {
Text("Next view")
} label: {
Text("Item \(item)")
}
}
}
}

Related

Navigation + Tabview + Sheet broken in iOS 15

It looks like Navigation + TabView + Sheet is broken in iOS 15.
When I do this:
ContentView -> DetailView -> Bottom Sheet
When the bottom sheet comes up, the Detail view is automatically popped off the stack:
https://www.youtube.com/watch?v=gguLptAx0l4
I expect the Detail view to stay there even when the bottom sheet appears. Does anyone have any idea on why this happens and how to fix it?
Here is my sample code:
import Combine
import SwiftUI
import RealmSwift
struct ContentView: View {
var body: some View {
NavigationView {
TabView {
TabItemView(num: 1)
.tabItem {
Text("One")
}
TabItemView(num: 2)
.tabItem {
Text("Two")
}
}
}
}
}
struct TabItemView: View {
private let num: Int
init(num: Int) {
self.num = num
}
var body: some View {
NavigationLink(destination: DetailView(text: "Detail View \(num)")) {
Text("Go to Detail View")
}
}
}
struct DetailView: View {
#State private var showingSheet = false
private let text: String
init(text: String) {
self.text = text
}
var body: some View {
Button("Open Sheet") {
showingSheet.toggle()
}.sheet(isPresented: $showingSheet) {
Text("Sheet Text")
}
}
}
This works on iOS 14 btw
UPDATE 1:
Tried #Sebastian's suggestion of putting NavigationView inside of TabView. While this fixed the nav bug, it fundamentally changed the behavior (I don't want to show the tabs in DetailView).
Also tried his suggestion of using Introspect to set navigationController.hidesBottomBarWhenPushed = true on the NavigationLink destination, but that didn't do anything:
struct ContentView: View {
var body: some View {
TabView {
NavigationView {
TabItemView(num: 1)
}.tabItem {
Text("One")
}
NavigationView {
TabItemView(num: 2)
}.tabItem {
Text("Two")
}
}
}
}
struct TabItemView: View {
private let num: Int
init(num: Int) {
self.num = num
}
var body: some View {
NavigationLink(destination: DetailView(text: "Detail View \(num)").introspectNavigationController { navigationController in
navigationController.hidesBottomBarWhenPushed = true
}) {
Text("Go to Detail View")
}
}
}
struct DetailView: View {
#State private var showingSheet = false
private let text: String
init(text: String) {
self.text = text
}
var body: some View {
Button("Open Sheet") {
showingSheet.toggle()
}.sheet(isPresented: $showingSheet) {
Text("Sheet Text")
}
}
}
You need to flip how you nest TabView & NavigationView. Instead of nesting several TabView views inside a NavigationView, use the TabView as the parent component, with a NavigationView for each tab.
This is how the updated ContentView would look like:
struct ContentView: View {
var body: some View {
TabView {
NavigationView {
TabItemView(num: 1)
}
.tabItem {
Text("One")
}
NavigationView {
TabItemView(num: 2)
}
.tabItem {
Text("Two")
}
}
}
}
This makes sense and is more correct: The tabs should always be visible, but you want to show a different navigation stack with different content in each tab.
That it worked previously doesn't make it more correct - SwiftUI probably just changed its mind on dealing with unexpected situations. That, and the lack of error messages in these situations, is the downside of using a framework that tries to render anything you throw at it!
If the goal is specifically to hide the tabs when pushing a new view on a NavigationView (e.g., when tapping on a conversation in a messaging app), you have to use a different solution. Apple added the UIViewController.hidesBottomBarWhenPushed property to UIKit to support this specific use case.
This property is set on the UIViewController that, when presented, should not show a toolbar. In other words: Not the UINavigationController or the UITabBarController, but the child UIViewController that you push onto the UINavigationController.
This property is not supported in SwiftUI natively. You could set it using SwiftUI-Introspect, or simply write the navigation structure of your application using UIKit and write the views inside in SwiftUI, linking them using UIHostingViewController.

SwiftUI UINavigationBar does not ignore the top safe area. How to get rid of empty space on the top?

Code I used to create an example of this navigation bar:
struct ContentView: View {
init() {
let defaultAppearance = UINavigationBarAppearance()
defaultAppearance.configureWithOpaqueBackground()
defaultAppearance.backgroundColor = .red
UINavigationBar.appearance().standardAppearance = defaultAppearance
UINavigationBar.appearance().setBackgroundImage(UIImage(), for: .default)
}
var body: some View {
ParentView()
}
}
struct ParentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Tap here")
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarHidden(true)
.edgesIgnoringSafeArea(.all)
}
}
}
struct DetailView: View {
var body: some View {
VStack {
Text("Detail View")
}
.edgesIgnoringSafeArea(.all)
}
}
It doesn't matter where I'm putting .edgesIgnoringSafeArea(.all) it doesn't work. Is there another way to tell Navigation Bar to ignore the safe area?
If it's not the safe area problem, then I need to make the whole navigation bar to have the same height of UINavigationBarContentView displayed in UI Hierarchy:

SwiftUI - TabView Overlay with custom view

in my last question I've asked about a method to achieve the following TabView overlay in SwiftUI:
Thanks again #davidev for the quick support.
I modified the solution with an opportunity to apply conditional view modifier. In this case .overlay().
extension View {
#ViewBuilder
func `if`<Content: View>(_ condition: Bool, content: (Self) -> Content) -> some View {
if condition {
content(self)
}
else {
self
}
}
}
The code snipped above empowered me to implement the conditional toolbar to appear when an observedObject is toggled (e.g. by a switch from read to edit mode):
.if(class.observedObject){ view in
view.overlay(ToolbarFromDavidev())
}
This also works but it's really buggy (e.g. the whole view navigation gets reset and I've limited styling opportunities).
This leads me to my question: Does someone of you have a reference implementation which I can use for my orientation? I would like to solve this with a ZStack that I can position over my TabView that I use for navigation. Like it's shown in the GIF above. However, I'm not able to position the ZStack over the TabView. Already tried stuff like ignoring safe areas etc.
Many Thanks!
You could overlay the TabView like so:
TabView {
...
}
.overlay(
VStack {
Spacer()
//Your View
.frame(height: /*Height of the TabBar*/)
}
.edgesIgnoringSafeArea(.all)
)
Now it comes down to finding out the height of the TabBar. Since its dynamic (different iPhone screen sizes) One way would be like so:
#State private var tabBarHeight: CGFloat = .zero
...
TabView {
//NavigationView
.background(
TabBarAccessor { tabBar in tabBarHeight = tabBar.bounds.height }
)
}
.overlay(
VStack {
Spacer()
//Your View
.frame(height: tabBarHeight)
}
.edgesIgnoringSafeArea(.all)
)
An example solution
Everything together - your code from your first answer modified.
Code:
struct ContentView: View {
#State private var tabBarHeight: CGFloat = .zero
#State var isSelecting : Bool = false
var body: some View {
TabView {
NavigationView {
Text("First Nav Page")
}
.tabItem {
Image(systemName: "house")
Text("Home")
}.tag(0)
.background(
TabBarAccessor { tabBar in tabBarHeight = tabBar.bounds.height }
)
NavigationView {
Text("Second Nav Page")
}
.tabItem {
Image(systemName: "gear")
Text("Settings")
}.tag(1)
Text("No Nav Page")
.tabItem{
Image(systemName: "plus")
Text("Test")
}.tag(2)
}
.overlay(
VStack {
Spacer()
Rectangle()
.foregroundColor(.green)
.frame(height: tabBarHeight)
}
.edgesIgnoringSafeArea(.all)
)
}
}
struct TabBarAccessor: UIViewControllerRepresentable {
var callback: (UITabBar) -> Void
private let proxyController = ViewController()
func makeUIViewController(context: UIViewControllerRepresentableContext<TabBarAccessor>) ->
UIViewController {
proxyController.callback = callback
return proxyController
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<TabBarAccessor>) {
}
typealias UIViewControllerType = UIViewController
private class ViewController: UIViewController {
var callback: (UITabBar) -> Void = { _ in }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let tabBar = self.tabBarController {
self.callback(tabBar.tabBar)
}
}
}
}
Note
The TabBarAccessor code I have found from: Here

How to disable NavigationView push and pop animations

Given this simple NavigationView:
struct ContentView : View {
var body: some View {
NavigationView {
VStack {
NavigationLink("Push Me", destination: Text("PUSHED VIEW"))
}
}
}
}
Did anyone find a way of disabling the NavigationView animation when a destination view is pushed/popped into/from the stack?
This has been possible in UIKit since iOS2.0! I think it is not too much to ask from the framework. I tried all sorts of modifiers on all views (i.e., the NavigationView container, the destination view, the NavigationLink, etc)
These are some of the modifiers I tried:
.animation(nil)
.transition(.identity)
.transaction { t in t.disablesAnimations = true }
.transaction { t in t.animation = nil }
None made a difference. I did not find anything useful in the EnvironmentValues either :-(
Am I missing something very obvious, or is the functionality just not there yet?
Xcode 11.3:
Right now there is no modifier to disable NavigationView animations.
You can use your struct init() to disable animations, as below:
struct ContentView : View {
init(){
UINavigationBar.setAnimationsEnabled(false)
}
var body: some View {
NavigationView {
VStack {
NavigationLink("Push Me", destination: Text("PUSHED VIEW"))
}
}
}
}
First you need state for the NavigationLink to respond to, then set that state inside a transaction with animations disabled, as follows:
struct ContentView : View {
#State var isActive = false
var body: some View {
NavigationView {
VStack {
NavigationLink(isActive: $isActive, destination: {
Text("PUSHED VIEW")}) {
Text("Push Me")
}
Button("Navigate Without Animation") {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isActive = true
}
}
}
}
}
}
I recently created an open source project called swiftui-navigation-stack (https://github.com/biobeats/swiftui-navigation-stack) that contains the NavigationStackView, a view that mimics the navigation behaviours of the standard NavigationView adding some useful features. For example, you could use the NavigationStackView and disable the transition animations as requested by Kontiki in the question. When you create the NavigationStackView just specify .none as transitionType:
struct ContentView : View {
var body: some View {
NavigationStackView(transitionType: .none) {
ZStack {
Color.yellow.edgesIgnoringSafeArea(.all)
PushView(destination: View2()) {
Text("PUSH")
}
}
}
}
}
struct View2: View {
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.all)
PopView {
Text("POP")
}
}
}
}
PushView and PopView are two views that allow you push and pop views (similar to the SwiftUI NavigationLink). Here is the complete example:
import SwiftUI
import NavigationStack
struct ContentView : View {
var body: some View {
NavigationStackView(transitionType: .none) {
ZStack {
Color.yellow.edgesIgnoringSafeArea(.all)
PushView(destination: View2()) {
Text("PUSH")
}
}
}
}
}
struct View2: View {
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.all)
PopView {
Text("POP")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The result is:
It would be great if you guys joined me in improving this open source project.

Show a new View from Button press Swift UI

I would like to be able to show a new view when a button is pressed on one of my views.
From the tutorials I have looked at and other answered questions here it seems like everyone is using navigation button within a navigation view, unless im mistaken navigation view is the one that gives me a menu bar right arrows the top of my app so I don't want that. when I put the navigation button in my view that wasn't a child of NavigationView it was just disabled on the UI and I couldn't click it, so I guess I cant use that.
The other examples I have seen seem to use presentation links / buttons which seem to show a sort of pop over view.
Im just looking for how to click a regular button and show another a view full screen just like performing a segue used to in the old way of doing things.
Possible solutions
1.if you want to present on top of current view(ex: presentation style in UIKit)
struct ContentView: View {
#State var showingDetail = false
var body: some View {
Button(action: {
self.showingDetail.toggle()
}) {
Text("Show Detail")
}.sheet(isPresented: $showingDetail) {
DetailView()
}
}
}
2.if you want to reset current window scene stack(ex:after login show home screen)
Button(action: goHome) {
HStack(alignment: .center) {
Spacer()
Text("Login").foregroundColor(Color.white).bold()
Spacer()
}
}
func goHome() {
if let window = UIApplication.shared.windows.first {
window.rootViewController = UIHostingController(rootView: HomeScreen())
window.makeKeyAndVisible()
}
}
3.push new view (ex: list->detail, navigation controller of UIKit)
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Show Detail View")
}.navigationBarTitle("Navigation")
}
}
}
}
4.update the current view based on #state property, (ex:show error message on login failure)
struct ContentView: View {
#State var error = true
var body: some View {
...
... //login email
.. //login password
if error {
Text("Failed to login")
}
}
}
For simple example you can use something like below
import SwiftUI
struct ExampleFlag : View {
#State var flag = true
var body: some View {
ZStack {
if flag {
ExampleView().tapAction {
self.flag.toggle()
}
} else {
OtherExampleView().tapAction {
self.flag.toggle()
}
}
}
}
}
struct ExampleView: View {
var body: some View {
Text("some text")
}
}
struct OtherExampleView: View {
var body: some View {
Text("other text")
}
}
but if you want to present more view this way looks nasty
You can use stack to control view state without NavigationView
For Example:
class NavigationStack: BindableObject {
let didChange = PassthroughSubject<Void, Never>()
var list: [AuthState] = []
public func push(state: AuthState) {
list.append(state)
didChange.send()
}
public func pop() {
list.removeLast()
didChange.send()
}
}
enum AuthState {
case mainScreenState
case userNameScreen
case logginScreen
case emailScreen
case passwordScreen
}
struct NavigationRoot : View {
#EnvironmentObject var state: NavigationStack
#State private var aligment = Alignment.leading
fileprivate func CurrentView() -> some View {
switch state.list.last {
case .mainScreenState:
return AnyView(GalleryState())
case .none:
return AnyView(LoginScreen().environmentObject(state))
default:
return AnyView(AuthenticationView().environmentObject(state))
}
}
var body: some View {
GeometryReader { geometry in
self.CurrentView()
.background(Image("background")
.animation(.fluidSpring())
.edgesIgnoringSafeArea(.all)
.frame(width: geometry.size.width, height: geometry.size.height,
alignment: self.aligment))
.edgesIgnoringSafeArea(.all)
.onAppear {
withAnimation() {
switch self.state.list.last {
case .none:
self.aligment = Alignment.leading
case .passwordScreen:
self.aligment = Alignment.trailing
default:
self.aligment = Alignment.center
}
}
}
}
.background(Color.black)
}
}
struct ExampleOfAddingNewView: View {
#EnvironmentObject var state: NavigationStack
var body: some View {
VStack {
Button(action:{ self.state.push(state: .emailScreen) }){
Text("Tap me")
}
}
}
}
struct ExampleOfRemovingView: View {
#EnvironmentObject var state: NavigationStack
var body: some View {
VStack {
Button(action:{ self.state.pop() }){
Text("Tap me")
}
}
}
}
In my opinion this bad way, but navigation in SwiftUI much worse