TabView, tabItem: running code on selection or adding an onTapGesture - swift

I would like to run some code when one of my tabs in tabview is selected.
Hypothetical: I want to create an app with the intentions to: A) Use a tabview and B) seriously confuse the user. To achieve this, I will create an app with tabs "one", "two", and "three", and views "one", "two", and "three". Then, when the second tab is selected, I will change view "two" to say "one" instead. Diabolical.
A common sense solution to this goes something like this:
import SwiftUI
struct ContentView: View {
#State private var two : String = "Two"
var body: some View {
TabView() {
Text("One")
.tabItem {
Text("One")
}
Text("Two")
.tabItem {
Text(two)
}
.onTapGesture {
print("Tapped!!")
self.two = "One"
}
Text("Three")
.tabItem {
Text("Three")
}
}
}
}
Unfortunately, this works exactly like a normal app and fails to confuse the user because two is not updated (and there is no "Tapped!" in the console).
How can I run code when a tabItem is selected or tapped? This could be updating variables, running an animation, or anything else.

Here is a solution - you can observe tab selection change and react correspondingly.
Tested with Xcode 12 / iOS 14
import Combine // << needed for Just publisher below
struct ContentView: View {
#State private var two : String = "Two"
#State private var selection: Int = 1
var body: some View {
TabView(selection: $selection) {
Text("One")
.tabItem {
Text("One")
}.tag(1)
Text("Two")
.tabItem {
Text(two)
}.tag(2)
Text("Three")
.tabItem {
Text("Three")
}.tag(3)
}
// .onChange(selection) { // << if SwiftUI 2.0 min spec
.onReceive(Just(selection)) {
print("Tapped!!")
if $0 == 2 {
self.two = "One"
}
}
}
}

Instead of using onTapGesture on tabView we can write an extension to Binding and it will detect the new tab selection value even if we tap the tab bar within the same tab it will detect the changes. Here I am provided the binding extension.
extension Binding {
func onUpdate(_ closure: #escaping () -> Void) -> Binding<Value> {
Binding(get: {
wrappedValue
}, set: { newValue in
wrappedValue = newValue
closure()
})
}}
I used this in my tabView. I attached my code below.
TabView(selection: $tabSelection.onUpdate {
setNewValue(value: tabSelection)
}) {
ContentView()
.tabItem {
Label {
Text("Home")
} icon: {
Image("HomePage_icon")
.renderingMode(.template)
}
}
.tag(TabViews.homepage)
}
SetNewValue function, this function acts like onTapGesture
func setNewValue(value: TabViews){
self.tabSelection = value
/* inside this function we can write the code, we like to write it in onTapGesture */
}

Related

Interacting with a confirmationDialog or alert is causing the parent view to pop

When you navigate and open the confirmation dialog. When you select Yes, No or Cancel the page that the app was on is dismissed and it takes you back to the form on the previous page.
We also found this happens with alerts too.
It's a simple enough app structure, top level tabs then a menu which links to sub pages.
Here is a quick demo of the bug:
We put together an example app that demonstrates this.
How can we prevent this from happening while also maintaining the app structure?
import SwiftUI
#main
struct testApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
TabView() {
Form {
NavigationLink(destination: SubPage()) {
Image(systemName: "clock")
Text("Sub Page")
}
// This is where more menu options would be
}
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
struct SubPage: View {
#State private var confirmDialogVisible = false
var body: some View {
VStack {
Button{
confirmDialogVisible = true
} label: {
Text("popup")
}
}
.confirmationDialog("Confirm?", isPresented: $confirmDialogVisible) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
}
We are using XCode 14.1
And running on iOS 16.1
I usually use ViewModifer to keep consistency between tabs. One modifier for the root of the tab and one for the children.
///Modifier that uses `ToolbarViewModifier` and includes a `NavigationView`
struct NavigationViewModifier: ViewModifier{
func body(content: Content) -> some View {
NavigationView{
content
.modifier(ToolbarViewModifier())
}
}
}
///`toolbar` that can be used by the root view of the navigation
///and the children of the navigation
struct ToolbarViewModifier: ViewModifier{
let title: String = "Company Name"
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItem(placement: .principal) {
VStack{
Image(systemName: "sparkles")
Text(title)
}
}
}
}
}
Then the Views use it something like this.
import SwiftUI
struct CustomTabView: View {
var body: some View {
TabView{
ForEach(0..<4){ n in
CustomChildView(title: n.description)
//Each tab gets a `NavigationView` and the shared toolbar
.modifier(NavigationViewModifier())
.tabItem {
Text(n.description)
}
}
}
}
}
struct CustomChildView: View {
let title: String
#State private var showConfirmation: Bool = false
var body: some View {
VStack{
Text(title)
NavigationLink {
CustomChildView(title: "\(title) :: \(UUID().uuidString)")
} label: {
Text("open child")
}
Button("show confirmation") {
showConfirmation.toggle()
}
.confirmationDialog("Confirm?", isPresented: $showConfirmation) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
//Each child uses the shared toolbar
.modifier(ToolbarViewModifier())
}
}
I stuck with NavigationView since that is what you have in your code but
if we take into consideration the new NavigationStack the possibilities of these two modifiers become exponentially better.
You can include custom back buttons that only appear if the path is not empty, return to the root from anywhere, etc.
Apple says that
Tab bars use bar items to navigate between mutually exclusive panes of content in the same view
Make sure the tab bar is visible when people navigate to different areas in your app
https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/tab-bars/
Having the NavigationView or NavigationStack on top goes against these guidelines and therefore are the source of endless bugs, Especially when you take into consideration iPadOS.
Simple solution would be to use navigationDestination.
struct testApp: App {
#State var goToSubPage = false
var body: some Scene {
WindowGroup {
NavigationStack {
TabView() {
Form {
VStack {
Image(systemName: "clock")
Text("Sub Page")
}
.onTapGesture {
goToSubPage = true
}
// This is where more menu options would be
}
.navigationDestination(isPresented: $goToSubPage, destination: {
SubPage()
})
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
I tested it and it won't popped off itself anymore.

How do I prevent the user from switching to certain tabs in a SwiftUI TabView

I am creating sort of a game in swift that gives the user a new clue every time they guess incorrectly. I would like to have a TabView for the clues, but I don’t want the player to have access to every clue at the start. I was thinking either locking the tabs in the TabView until a function is called or making an array of different views in the TabView that can be edited.
One note before starting my reply: in this community you need to be clear about the issue and show examples of your code, error messages, points where you get stuck. Your answer is quite generic, but I will give you some hints.
Each Tab is a view (almost) like any other one, it can be hidden conditionally.
You can just hide the tabs until the user reaches the point in the game where the clues must be shown. In addition, or in alternative, you can switch to different views to see each clue.
See an example below (there's room for improvement, this is the idea):
struct Tabs: View {
#State private var showTab2 = false // This will trigger the tab to be shown
// It can be stored in your view model
var body: some View {
TabView {
Example(showNextClue: $showTab2)
.tabItem {Label("Tab 1", systemImage: "checkmark")}
if showTab2 { // Tab 2 is hidden until you change the state variable
Tab2()
.tabItem {Label("Tab 2", systemImage: "flag")}
}
Example(showNextClue: $showTab2)
.tabItem {Label("Tab 3", systemImage: "trash")}
}
}
}
struct Tab2: View {
#State private var clueIndex = 0 // What clue will you show now?
var body: some View {
VStack {
switch clueIndex { // Show the applicable clue
// You can iterate through an array of views as you proposed, chose the best method
case 0:
Text("Now you can see this clue number 1")
case 1:
Text("Clue number 2")
default:
Text("Clue \(String(clueIndex))")
}
Button {
clueIndex += 1
} label: {
Text("Show next clue")
}
.padding()
}
}
}
struct Example: View {
#Binding var showNextClue: Bool
var body: some View {
VStack {
Text("This is open")
.padding()
Button {
showNextClue.toggle()
} label: {
Text(showNextClue ? "Now you can see the next clue" : "Click here to see your next clue")
}
}
}
}
Unfortunately you cannot disable the standard TabBar selectors. But you can do your own custom ones, e.g. like this:
struct ContentView: View {
#State private var currentTab = "Home"
#State private var activeTabs: Set<String> = ["Home"] // saves the activated tabs
var body: some View {
VStack {
TabView(selection: $currentTab) {
// Tab Home
VStack(spacing: 20) {
Text("Home Tab")
Button("Unlock Hint 1") { activeTabs.insert("Hint 1")}
Button("Unlock Hint 2") { activeTabs.insert("Hint 2")}
}
.tag("Home")
// Tab 1. Hint
VStack {
Text("Your first Hint")
}
.tag("Hint 1")
// Tab 2. Hint
VStack {
Text("Your Second Hint")
}
.tag("Hint 2")
}
// custom Tabbar buttons
Divider()
HStack {
OwnTabBarButton("Home", imageName: "house.fill")
OwnTabBarButton("Hint 1", imageName: "1.circle")
OwnTabBarButton("Hint 2", imageName: "2.circle")
}
}
}
func OwnTabBarButton(_ label: String, imageName: String) -> some View {
Button {
currentTab = label
} label: {
VStack {
Image(systemName: imageName)
Text(label)
}
}
.disabled(!activeTabs.contains(label))
.padding([.horizontal,.top])
}
}

SwiftUI TabView: how to detect click on a tab?

I would like to run a function each time a tab is tapped.
On the code below (by using onTapGesture) when I tap on a new tab, myFunction is called, but the tabview is not changed.
struct DetailView: View {
var model: MyModel
#State var selectedTab = 1
var body: some View {
TabView(selection: $selectedTab) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
.onTapGesture {
model.myFunction(item: selectedTab)
}
}
}
How can I get both things:
the tabview being normally displayed
my function being called
As of iOS 14 you can use onChange to execute code when a state variable changes. You can replace your tap gesture with this:
.onChange(of: selectedTab) { newValue in
model.myFunction(item: newValue)
}
If you don't want to be restricted to iOS 14 you can find additional options here: How can I run an action when a state changes?
The above answers work well except in one condition. If you are present in the same tab .onChange() won't be called. the better way is by creating an extension to binding
extension Binding {
func onUpdate(_ closure: #escaping () -> Void) -> Binding<Value> {
Binding(get: {
wrappedValue
}, set: { newValue in
wrappedValue = newValue
closure()
})
}
}
the usage will be like this
TabView(selection: $selectedTab.onUpdate{ model.myFunction(item: selectedTab) }) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel:
Full module code:
import SwiftUI
struct TestPopToRootInTab: View {
#State private var selection = 0
#State private var resetNavigationID = UUID()
var body: some View {
let selectable = Binding( // << proxy binding to catch tab tap
get: { self.selection },
set: { self.selection = $0
// set new ID to recreate NavigationView, so put it
// in root state, same as is on change tab and back
self.resetNavigationID = UUID()
})
return TabView(selection: selectable) {
self.tab1()
.tabItem {
Image(systemName: "1.circle")
}.tag(0)
self.tab2()
.tabItem {
Image(systemName: "2.circle")
}.tag(1)
}
}
private func tab1() -> some View {
NavigationView {
NavigationLink(destination: TabChildView()) {
Text("Tab1 - Initial")
}
}.id(self.resetNavigationID) // << making id modifiable
}
private func tab2() -> some View {
Text("Tab2")
}
}
struct TabChildView: View {
var number = 1
var body: some View {
NavigationLink("Child \(number)",
destination: TabChildView(number: number + 1))
}
}
struct TestPopToRootInTab_Previews: PreviewProvider {
static var previews: some View {
TestPopToRootInTab()
}
}

How to trigger sheet on TabView click

How can I show a sheet when I click a tab in TabView? All the examples on the internet use a Button to trigger an update but I want to make the sheet appear when a user clicks one of the tabs in TabView.
I tried changing the boolean state variable in a tabbed view by adding .onAppear(), but it doesn't seem to work.
struct ContentView: View {
#State var showSheet: Bool = false
var body: some View {
return TabView {
HomeView()
.tabItem {
Image(systemName: "house")
}
}
.sheet(isPresented: self.$showSheet) {
SheetView(isShown: self.$showSheet)
}
}
}
In the above example, I basically want SheetView to show up when I click the tab. I don't want to replace HomeView with SheetView since I want it to be a sheet instead of static view. Thanks!
This can be achieved, albeit rather hackishly, by moving your State variables up one level and controlling the flow within a Group. Here, I just moved them to the app state for simplicity.
final class AppState: ObservableObject {
#Published var shouldShowActionSheet: Bool = true
#Published var selectedContentViewTab: ContentViewTabs = .none
}
In SceneDelegate.swift:
. . .
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(AppState()))
. . .
And finally in your view:
public enum ContentViewTabs: Hashable {
case none
case home
}
struct ContentView: View {
#EnvironmentObject var appState: AppState
var body: some View {
Group {
if (appState.selectedContentViewTab == .home && self.appState.shouldShowActionSheet) {
Text("").sheet(isPresented: self.$appState.shouldShowActionSheet, onDismiss: {
self.appState.shouldShowActionSheet.toggle()
self.appState.selectedContentViewTab = .none
}, content: {
Text("Oll Korrect, Chaps!")
})
} else {
TabView(selection: self.$appState.selectedContentViewTab) {
Text("First View")
.font(.title)
.tabItem {
VStack {
Image("first")
Text("First")
}
}.tag(ContentViewTabs.home)
}
}
}
}
}

NavigationLink isActive does not work inside navigationBarItems(trailing:) modifier

I am using the newest versions of Xcode (11 Beta 16) and macOS (10.15 Beta 6)
I am trying to create two views. From the first one view you should be able to navigate to the second one view via the trailing navigation bar item and to navigate back you should be able to use the system generated back button (which works) and additionally a trailing navigation bar button (which has some additional functionality like saving the data but this is not important for my problem).
Option 1 does work but if you comment out option 1 and uncomment option 2 (my wanted layout) the done button just does not navigate back.
struct ContentView1: View {
#State var show = false
var body: some View {
NavigationView {
Form {
Text("View 1")
// Option 1 that does work
NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
Text("Move")
}
}
.navigationBarTitle(Text("Title"))
// Option 2 that does NOT work
// .navigationBarItems(trailing: NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
// Text("Move")
// })
}
}
}
struct ContentView2: View {
#Binding var show: Bool
var body: some View {
Form {
Text("View 2")
Text(show.description)
}
.navigationBarItems(trailing: Button(action: {
self.show = false
}, label: {
Text("Done")
}))
}
}
Any suggestions how to fix that?
Option 2 plays nicely with presentationMode:
struct ContentView2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form {
Text("View 2")
}
.navigationBarItems(trailing: Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Done")
}))
}
}