SwiftUI Disable specific tabItem selection in a TabView? - ios

I have a TabView that presents a sheet after tapping on the [+] (2nd) tabItem. At the same time, the ContentView is also switching the TabView's tab selection, so when I dismiss the sheet that is presented, the selected tab is a blank one without any content. Not an ideal user experience.
My question:
I am wondering how I can simply disable that specific tabItem so it doesn't "behave like a tab" and simply just present's the sheet while maintaining the previous tab selection prior to tapping the [+] item. Is this possible with SwiftUI or should I got about this another way to achieve this effect?
Image of my tab bar:
Here's the code for my ContentView where my TabView is:
struct SheetPresenter<Content>: View where Content: View {
#EnvironmentObject var appState: AppState
#Binding var isPresenting: Bool
var content: Content
var body: some View {
Text("")
.sheet(isPresented: self.$isPresenting, onDismiss: {
// change back to previous tab selection
print("New listing sheet was dismissed")
}, content: { self.content})
.onAppear {
DispatchQueue.main.async {
self.isPresenting = true
print("New listing sheet appeared with previous tab as tab \(self.appState.selectedTab).")
}
}
}
}
struct ContentView: View {
#EnvironmentObject var appState: AppState
#State private var selection = 0
#State var newListingPresented = false
var body: some View {
$appState.selectedTab back to just '$selection'
TabView(selection: $appState.selectedTab){
// Browse
BrowseView()
.tabItem {
Image(systemName: (selection == 0 ? "square.grid.2x2.fill" : "square.grid.2x2")).font(.system(size: 22))
}
.tag(0)
// New Listing
SheetPresenter(isPresenting: $newListingPresented, content: NewListingView(isPresented: self.$newListingPresented))
.tabItem {
Image(systemName: "plus.square").font(.system(size: 22))
}
.tag(1)
// Bag
BagView()
.tabItem {
Image(systemName: (selection == 2 ? "bag.fill" : "bag")).font(.system(size: 22))
}
.tag(2)
// Profile
ProfileView()
.tabItem {
Image(systemName: (selection == 3 ? "person.crop.square.fill" : "person.crop.square")).font(.system(size: 22))
}
.tag(3)
}.edgesIgnoringSafeArea(.top)
}
}
And here's AppState:
final class AppState: ObservableObject {
#Published var selectedTab: Int = 0
}

You are pretty close to what you want to achieve. You will just need to preserve the previous selected tab index and reset the current selected tab index with that preserved value at the time of the dismissal of the sheet. That means:
.sheet(isPresented: self.$isPresenting, onDismiss: {
// change back to previous tab selection
self.appState.selectedTab = self.appState.previousSelectedTab
}, content: { self.content })
So how do you keep track of the last selected tab index that stays in sync with the selectedTab property of the AppState? There may be more ways to do that with the APIs from Combine framework itself, but the simplest solution that comes to my mind is:
final class AppState: ObservableObject {
// private setter because no other object should be able to modify this
private (set) var previousSelectedTab = -1
#Published var selectedTab: Int = 0 {
didSet {
previousSelectedTab = oldValue
}
}
}
Caveats:
The above solution of may not be the exact thing as disable specific tab item selection but after you dismiss the sheet it will revert back with a soothing animation to the selected tab prior to presenting the sheet. Here is the result.

You may add something in the dismiss of sheet to switch the tabView to other tabs. Maybe you can insert some animation during the process.
struct SheetPresenter<Content>: View where Content: View {
#EnvironmentObject var appState: AppState
#Binding var isPresenting: Bool
#Binding var showOtherTab: Int
var content: Content
var body: some View {
Text("")
.sheet(isPresented: self.$isPresenting,
onDismiss: {
// change back to previous tab selection
self.showOtherTab = 0
} ,
content: { self.content})
.onAppear {
DispatchQueue.main.async {
self.isPresenting = true
print("New listing sheet appeared with previous tab as tab \(self.appState.selectedTab).")
}
}
}
}
struct ContentView: View {
#EnvironmentObject var appState: AppState
#State private var selection = 0
#State var newListingPresented = false
var body: some View {
// $appState.selectedTab back to just '$selection'
TabView(selection: $appState.selectedTab){
// Browse
Text("BrowseView") //BrowseView()
.tabItem {
Image(systemName: (selection == 0 ? "square.grid.2x2.fill" : "square.grid.2x2"))
.font(.system(size: 22))
} .tag(0)
// New Listing
SheetPresenter(isPresenting: $newListingPresented,
showOtherTab: $appState.selectedTab,
content: Text("1232"))//NewListingView(isPresented: self.$newListingPresented))
.tabItem {
Image(systemName: "plus.square")
.font(.system(size: 22))
} .tag(1)
// Bag
// BagView()
Text("BAGVIEW")
.tabItem {
Image(systemName: (selection == 2 ? "bag.fill" : "bag"))
.font(.system(size: 22))
} .tag(2)
// Profile
Text("ProfileView") // ProfileView()
.tabItem {
Image(systemName: (selection == 3 ? "person.crop.square.fill" : "person.crop.square"))
.font(.system(size: 22))
} .tag(3)
} .edgesIgnoringSafeArea(.top)
}
}

I was able to replicate the following behaviors of the tabview of Instagram using SwiftUI and MVVM:
when the middle tab is selected, a modal view will open
when the middle tab is closed, the previously selected tab is again selected, not the middle tab
A. ViewModels (one for the whole tabview and another for a specific tab)
import Foundation
class TabContainerViewModel: ObservableObject {
//tab with sheet that will not be selected
let customActionTab: TabItemViewModel.TabItemType = .addPost
//selected tab: this is the most important code; here, when the selected tab is the custom action tab, set the flag that is was selected, then whatever is the old selected tab, make it the selected tab
#Published var selectedTab: TabItemViewModel.TabItemType = .feed {
didSet{
if selectedTab == customActionTab {
customActionTabSelected = true
selectedTab = oldValue
}
}
}
//flags whether the middle tab is selected or not
var customActionTabSelected: Bool = false
//create the individual tabItemViewModels that will get displayed
let tabItemViewModels:[TabItemViewModel] = [
TabItemViewModel(imageName:"house.fill", title:"Feed", type: .feed),
TabItemViewModel(imageName:"magnifyingglass.circle.fill", title:"Search", type: .search),
TabItemViewModel(imageName:"plus.circle.fill", title:"Add Post", type: .addPost),
TabItemViewModel(imageName:"heart.fill", title:"Notifications", type: .notifications),
TabItemViewModel(imageName:"person.fill", title:"Profile", type: .profile),
]
}
//this is the individual tabitem ViewModel
import SwiftUI
struct TabItemViewModel: Hashable {
let imageName:String
let title:String
let type: TabItemType
enum TabItemType {
case feed
case search
case addPost
case notifications
case profile
}
}
B. View (makes use of the ViewModels)
import SwiftUI
struct TabContainerView: View {
#StateObject private var tabContainerViewModel = TabContainerViewModel()
#ViewBuilder
func tabView(for tabItemType: TabItemViewModel.TabItemType) -> some View {
switch tabItemType {
case .feed:
FeedView()
case .search:
SearchView()
case .addPost:
AddPostView(tabContainerViewModel: self.tabContainerViewModel)
case .notifications:
NotificationsView()
case .profile:
ProfileView()
}
}
var body: some View {
TabView(selection: $tabContainerViewModel.selectedTab){
ForEach(tabContainerViewModel.tabItemViewModels, id: \.self){ viewModel in
tabView(for: viewModel.type)
.tabItem {
Image(systemName: viewModel.imageName)
Text(viewModel.title)
}
.tag(viewModel.type)
}
}
.accentColor(.primary)
.sheet(isPresented: $tabContainerViewModel.customActionTabSelected) {
PicsPicker()
}
}
}
struct TabContainerView_Previews: PreviewProvider {
static var previews: some View {
TabContainerView()
}
}
Note: In the course of my investigation, I tried adding code to onAppear in the middle tab. However, I found out that there is a current bug in SwiftUI that fires the onAppear even if a different tab was tapped. So the above seems to be the best way.
Happy coding!
References:
https://www.youtube.com/watch?v=SZj3CjMfT-8

Related

Get the view which was dismissed and from which the current view is display

I have a NavigationView which is showing a different view via fullScreenCover when I press a Button. Now I need to know when my view from where I pressed the Button is getting visible again and what was the view before. In the view shown by the fullScreenCover im using #Environment(\.dismiss) var dismiss to dismiss it.
So here is my concrete use case:
I have my main screen with two Buttons A and B.
A is showing sub view 1 and B is showing sub view 2.
When I dismiss one of these sub screens I need to know if I was in A view 1 or 2 before.
Is this somehow possible?
You can use the .onDisappear on the view that is linked on the destination of the NavigationLink. See this example:
import SwiftUI
struct ContentView: View {
#State var selectedSubView: ViewSelection = ViewSelection.zero
var body: some View {
NavigationView {
VStack {
// Version 1
NavigationLink (
destination: SubView1()
.onDisappear {
self.selectedSubView = .one
print(self.selectedSubView) // debug
},
label: {
Text("To ViewOne").padding()
})
// Version 2
NavigationLink (
destination: SubView2()
.onDisappear {
self.selectedSubView = .two
print(self.selectedSubView) // debug
},
label: {
Text("To ViewTwo")
.padding()
})
}
}
}
}
struct SubView1: View {
var body: some View {
Text("View 1")
}
}
struct SubView2: View {
var body: some View {
Text("View 2")
}
}
enum ViewSelection {
case one
case two
case zero
}
You can use .fullScreenCover with an onDismiss closure.
(Here I used .sheet because it's easier to test, but it's the same)
struct SwiftUIView4: View {
#State private var destination: Destination?
#State private var showed: Destination?
#State private var dismissed: Destination?
var body: some View {
VStack {
HStack {
Button("A") {
destination = .firstView
showed = .firstView
}.padding()
Spacer()
Button("B") {
destination = .secondView
showed = .secondView
}.padding()
}
Text("Just dismissed : \(dismissed?.rawValue ?? "nothing")")
.sheet(item: $destination, onDismiss: {
dismissed = showed
}, content: { destination in
switch destination {
case .firstView: Text("A clicked")
case .secondView: Text("B clicked")
}
})
}
}
}
enum Destination: String, Identifiable {
case firstView, secondView
var id: Destination { self }
}
Or you could use a custom Binding for the item parameter of your .fullScreenCover :
struct SwiftUIView4: View {
#State private var showed: Destination?
#State private var dismissed: Destination?
var body: some View {
VStack {
HStack {
Button("A") {
showed = .firstView
}.padding()
Spacer()
Button("B") {
showed = .secondView
}.padding()
}
Text("Just dismissed : \(dismissed?.rawValue ?? "nothing")")
.sheet(item: .init(get: {showed}, set: {
dismissed = showed // HERE
showed = $0 // <<<<
})) { destination in
switch destination {
case .firstView: Text("A clicked")
case .secondView: Text("B clicked")
}
}
}
}
}

SwiftUI updating #Binding value in NavigationLink(destination:label) causes destination to reappear

I have a weird issue, where I navigate to EditView and edit a state variable from ContentView. After that I dismiss EditView with presentation.wrappedValue.dismiss(). The problem is, as soon as the view is dismissed, it reappears again.
I'm using XCode 12.4 and my iOS deployment target is set to 14.4
Observations:
EditView doesn't reappear if the value isn't edited
removing the changed value Text("Value: \(title)") >> Text("Value") from ContentView tree resolves the issue, but that obviously isn't a solution.
moving the NavigationLink from .toolbar e.g.
VStack {
Text("Value: \(title)")
NavigationLink(destination: EditView(title: $title)){
Text("Edit")
}
}
also resolves the issue, but that seems like a hack. Besides, I'd like to keep using .toolbar because I like the .navigationTitle animation and I can't have a button in the upper-right corner of the screen if I have a navigation title without the toolbar.
Here's the full code:
import SwiftUI
struct ContentView: View {
#State var title: String = "Title"
#State var isActive: Bool = false
var body: some View {
NavigationView {
Text("Value: \(title)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(destination: EditView(title: $title, isActive: $isActive), isActive: $isActive){
Text("Edit")
}
}
}
}
}
}
struct EditView: View {
#Environment(\.presentationMode) var presentation
#Binding var title: String
#Binding var isActive: Bool
var body: some View {
Button(action: {
title = "\(Date().timeIntervalSince1970)"
// presentation.wrappedValue.dismiss()
isActive = false
}){
Text("Done")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
As far as I can tell this is a .toolbar bug, and if it turns out that way I'll report it to Apple, but in the meantime, does anyone have a better solution and/or explanation for this?
Cheers!
EDIT:
I updated the code with isActive value for the NavigationLink. It doesn't work when written like that, but uncommenting the commented out line makes it work. But that's quite hacky.
You are mixing up 2 things NavigationLink will push the view on stack, just like NavigationController in swift. That’s why you can see back button after navigating to second view. When you hit back button, it will pop top most view out of stack. presentationMode is not needed , dismissing presented view will not pop it of the stack.
To present a view and dismiss it you can check below code.
import SwiftUI
struct ContentViewsss: View {
#State var title: String = "Title"
#State var isPresented = false
var body: some View {
NavigationView {
Text("Value: \(title)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
isPresented.toggle()
}){
Text("Edit")
}
}
}
}.sheet(isPresented: $isPresented, content: {
EditView(title: $title, state: $isPresented)
})
}
}
struct EditView: View {
#Binding var title: String
#Binding var state: Bool
var body: some View {
Button(action: {
title = "\(Date().timeIntervalSince1970)"
state.toggle()
}){
Text("Done")
}
}
}
If you want NavigationLink functionality, you can just remove presentationMode code from second view, and keep ContentView as it is -:
struct EditView: View {
//#Environment(\.presentationMode) var presentation
#Binding var title: String
var body: some View {
Button(action: {
title = "\(Date().timeIntervalSince1970)"
// presentation.wrappedValue.dismiss()
}){
Text("Done")
}
}
}

SwiftUI. Present a detail view pushed from the root view as the initial application view

I have two SwiftUI views, the first view has a navigation link to the second view and I want to show the second view that is "pushed" out of the first view, as the initial application view.
This is the behavior of the iOS Notes app, where users see a list of notes as the initial view controller and can return to the folder list with the back navigation button.
Can I implement this with SwiftUI and how?
Here is a simple demo. Prepared & tested with Xcode 11.7 / iOS 13.7
struct ContentView: View {
#State private var isActive = false
var body: some View {
NavigationView {
NavigationLink(destination: Text("Second View"), isActive: $isActive) {
Text("First View")
}
}
.onAppear { self.isActive = true }
}
}
you can add another state variable to hide the first view until the second view appears on the screen.
struct ContentView1: View {
#State private var isActive = false
#State private var showView = false
var body: some View {
NavigationView {
NavigationLink(destination: Text("Second View")
.onAppear {
self.showView = true
},
isActive: $isActive) {
if self.showView {
Text("First View")
} else {
EmptyView()
}
}
}
.onAppear {
self.isActive = true
}
}
}
As mentioned in my comments to another answer, by setting an initial state for a variable that controls the presentation of the second view to true, your ContentView presents this second view as the initial view.
I've tested this using the simulator and on device. This appears to solve your problem and does not present the transition from the first view to the second view to the user - app opens to the second view.
struct ContentView: View {
#State private var isActive = true
var body: some View {
NavigationView {
NavigationLink(destination: Text("Second View"), isActive: $isActive) {
Text("First View")
}
}
}
}
I made my own implementation based on #Asperi and #Mohammad Rahchamani answers.
This implementation allows you to navigate even from a list with multiple navigation links. Tested on Xcode 12 with SwiftUI 2.0.
struct IOSFolderListView: View {
#State var isActive = false
#State var wasViewShown = false
var body: some View {
let list = List {
NavigationLink(destination: Text("SecondView").onAppear {
self.wasViewShown = true
}, isActive: $isActive) {
Text("SecondView")
}
NavigationLink(destination: Text("ThirdView")) {
Text("ThirdView")
}
.onAppear {
self.isActive = false
}
}
if wasViewShown {
list.listStyle(GroupedListStyle())
.navigationBarTitle("FirstView")
.navigationBarItems(leading: Image(systemName: "folder.badge.plus"), trailing: Image(systemName: "square.and.pencil"))
} else {
list.opacity(0)
.onAppear {
self.isActive = true
}
}
}
}

Why onAppear called again after onDisappear while switching tab in TabView in SwiftUI?

I am calling API when tab item is appeared if there is any changes. Why onAppear called after called onDisappear?
Here is the simple example :
struct ContentView: View {
var body: some View {
TabView {
NavigationView {
Text("Home")
.navigationTitle("Home")
.onAppear {
print("Home appeared")
}
.onDisappear {
print("Home disappeared")
}
}
.tabItem {
Image(systemName: "house")
Text("Home")
}.tag(0)
NavigationView {
Text("Account")
.navigationTitle("Account")
.onAppear {
print("Account appeared")
}
.onDisappear {
print("Account disappeared")
}
}
.tabItem {
Image(systemName: "gear")
Text("Account")
}.tag(1)
}
}
}
Just run above code and we will see onAppear after onDisappear.
Home appeared
---After switch tab to Account---
Home disappeared
Account appeared
Home appeared
Is there any solution to avoid this?
It's very annoying bug, imagine this scenario:
Home view onAppear method contains a timer which is fetching data repeatedly.
Timer is triggered invisibly by switching to the Account view.
Workaround:
Create a standalone view for every embedded NavigationView content
Pass the current selection value on to standalone view as #Binding parameter
E.g.:
struct ContentView: View {
#State var selected: MenuItem = .HOME
var body: some View {
return TabView(selection: $selected) {
HomeView(selectedMenuItem: $selected)
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
VStack {
Image(systemName: "house")
Text("Home")
}
}
.tag(MenuItem.HOME)
AccountView(selectedMenuItem: $selected)
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
VStack {
Image(systemName: "gear")
Text("Account")
}
}
.tag(MenuItem.ACCOUNT)
}
}
}
enum MenuItem: Int, Codable {
case HOME
case ACCOUNT
}
HomeView:
struct HomeView: View {
#Binding var selectedMenuItem: MenuItem
var body: some View {
return Text("Home")
.onAppear(perform: {
if MenuItem.HOME == selectedMenuItem {
print("-> HomeView")
}
})
}
}
AccountView:
struct AccountView: View {
#Binding var selectedMenuItem: MenuItem
var body: some View {
return Text("Account")
.onAppear(perform: {
if MenuItem.ACCOUNT == selectedMenuItem {
print("-> AccountView")
}
})
}
}
To whom it may help.
Because this behaviour I only could reproduce on iOS 14+, I end up using https://github.com/NicholasBellucci/StatefulTabView (which properly only get called when showed; but don't know if it's a bug or not, but it works with version 0.1.8) and TabView on iOS 13+.
I'm not sure why you are seeing that behaviour in your App. But I can explain why I was seeing it in my App.
I had a very similar setup to you and was seeing the same behaviour running an iOS13 App on iOS14 beta. In my Home screen I had a custom Tab Bar that would animate in and out when a detail screen was displayed. The code for triggering the hiding of the Tab Bar was done in the .onAppear of the Detail screen. This was triggering the Home screen to be redrawn and the .onAppear to be called. I removed the animation and found a much better set up due to this bug and the Home screen .onAppear stopped being called.
So if you have something in your Account Screen .onAppear that has a visual effect on the Home Screen then try commenting it out and seeing if it fixes the issue.
Good Luck.
I have been trying to understand this behavior for a number of days now. If you are working with a TabView, all of your onAppears() / onDisapear() will fire immediately on app init and never again. Which actually makes since I guess?
This was my solution to fix this:
import SwiftUI
enum TabItems {
case one, two
}
struct ContentView: View {
#State private var selection: TabItems = .one
var body: some View {
TabView(selection: $selection) {
ViewOne(isSelected: $selection)
.tabBarItem(tab: .one, selection: $selection)
ViewTwo(isSelected: $selection)
.tabBarItem(tab: .two, selection: $selection)
}
}
}
struct ViewOne: View {
#Binding var isSelected: TabItems
var body: some View {
Text("View One")
.onChange(of: isSelected) { _ in
if isSelected == .one {
// Do something
}
}
}
}
struct ViewTwo: View {
#Binding var isSelected: TabItems
var body: some View {
Text("View Two")
.onChange(of: isSelected) { _ in
if isSelected == .two {
// Do something
}
}
}
}
View Modifier for custom TabView
struct TabBarItemsPreferenceKey: PreferenceKey {
static var defaultValue: [TabBarItem] = []
static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
value += nextValue()
}
}
struct TabBarItemViewModifer: ViewModifier {
let tab: TabBarItem
#Binding var selection: TabBarItem
func body(content: Content) -> some View {
content
.opacity(selection == tab ? 1.0 : 0.0)
.preference(key: TabBarItemsPreferenceKey.self, value: [tab])
}
}
extension View {
func tabBarItem(tab: TabBarItem, selection: Binding<TabBarItem>) -> some View {
modifier(TabBarItemViewModifer(tab: tab, selection: selection))
}
}

SwiftUI transition from modal sheet to regular view with Navigation Link

I'm working with SwiftUI and I have a starting page. When a user presses a button on this page, a modal sheet pops up.
In side the modal sheet, I have some code like this:
NavigationLink(destination: NextView(), tag: 2, selection: $tag) {
EmptyView()
}
and my modal sheet view is wrapped inside of a Navigation View.
When the value of tag becomes 2, the view does indeed go to NextView(), but it's also presented as a modal sheet that the user can swipe down from, and I don't want this.
I'd like to transition from a modal sheet to a regular view.
Is this possible? I've tried hiding the navigation bar, etc. but it doesn't seem to make a difference.
Any help with this matter would be appreciated.
You can do this by creating an environmentObject and bind the navigationLink destination value to the environmentObject's value then change the value of the environmentObject in the modal view.
Here is a code explaining what I mean
import SwiftUI
class NavigationManager: ObservableObject{
#Published private(set) var dest: AnyView? = nil
#Published var isActive: Bool = false
func move(to: AnyView) {
self.dest = to
self.isActive = true
}
}
struct StackOverflow6: View {
#State var showModal: Bool = false
#EnvironmentObject var navigationManager: NavigationManager
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: self.navigationManager.dest, isActive: self.$navigationManager.isActive) {
EmptyView()
}
Button(action: {
self.showModal.toggle()
}) {
Text("Show Modal")
}
}
}
.sheet(isPresented: self.$showModal) {
secondView(isPresented: self.$showModal).environmentObject(self.navigationManager)
}
}
}
struct StackOverflow6_Previews: PreviewProvider {
static var previews: some View {
StackOverflow6().environmentObject(NavigationManager())
}
}
struct secondView: View {
#EnvironmentObject var navigationManager: NavigationManager
#Binding var isPresented: Bool
#State var dest: AnyView? = nil
var body: some View {
VStack {
Text("Modal view")
Button(action: {
self.isPresented = false
self.dest = AnyView(thirdView())
}) {
Text("Press me to navigate")
}
}
.onDisappear {
// This code can run any where but I placed it in `.onDisappear` so you can see the animation
if let dest = self.dest {
self.navigationManager.move(to: dest)
}
}
}
}
struct thirdView: View {
var body: some View {
Text("3rd")
.navigationBarTitle(Text("3rd View"))
}
}
Hope this helps, if you have any questions regarding this code, please let me know.

Resources