TabStar
allows users to use an app’s tab bar to navigate backwards on a navigation stack, and also gives apps the ability to customize what happens when users re-select a tab.
This package was originally written by yours truly for Mlem for Lemmy on iOS, an open-source Lemmy client...go check it out! 😇
Screen.Recording.2023-12-23.at.1.29.58.AM.mov
- Reliably dismiss views on a
NavigationStack
via system or custom tab bar. - Allow apps to customize tab re-selection behaviour (e.g. scroll-to-top before dismissing view).
- All in a neat little package 😎
Add TabStar
to your Xcode project by adding a package dependency to your Package.swift
file.
dependencies: [
.package(url: "https://github.com/boscojwho/TabStar.git", from: "1.0.0")
]
Alternatively, open your Xcode project, and navigate to File > Swift Packages > Add Package Dependency...
and enter https://github.com/boscojwho/TabStar.git
.
-
This doesn't come built-in to SwiftUI's
TabView
(yes, it's built-in toUITabBarController
). -
Reliability
-
Doesn’t SwiftUI already provide a way to programmatically manipulate a
NavigationStack
’s path whether you useNavigationPath
or a custom path type? -
Yes, but unfortunately programmatic path manipulation causes the path/UI states to become corrupt on both iOS 16/17, see here for a sample project demonstrating this issue.
-
Essentially, if users rapidly trigger programmatic navigation path manipulation while a dismiss action is in-flight, the path’s state and the stack’s UI state become de-synced – this is easily reproducible. When that happens, users can no longer properly navigate using onscreen buttons, and apps performing programmatic path manipulation will encounter unexpected behaviour.
-
TabStar
helps by relying on SwiftUI’s@Environment(\.dismiss)
action to perform programmatic dismissal. ThatDismissAction
is reliable because it is synced to the onscreen state ofNavigationStack
. Essentially, it doesn’t allow for dismiss actions to happen if one is already in-flight or if the view associated with a dismiss action isn’t yet visible.
TabStar
performs two types of tab bar navigation actions:
- Primary: This is the dismiss action, and is always performed last.
- Auxiliary: This is where apps can customize tab re-selection to perform any view-specific behaviours. The simplest example is “scroll-to-top”. Other examples may include scrolling up posts one-by-one in a Mastodon client app.
Hint: See the example project included with this package for demo code.
For either SwiftUI.TabView
or any custom tab view, you will need to ensure that tab selection triggers a change update on re-select. This functionality is currently not provided by TabStar
. For an example implementation, see TabReselection
in the example project.
Once you are able to detect when users re-select a selected tab, you are ready to integrate TabStar
to allow users to perform custom actions via the tab bar.
To integrate TabStar
into your app, you will need to start by configuring each tab’s root view.
- Each tab will need to have its own
NavigationStack
. Optionally, you may configure a tab with aNavigationSplitView
(see example app on how to configure a Split View). - Add the following properties to a tab’s root view:
@StateObject private var navigation: Navigation = .init()
AND
@State private var navigationPath: NavigationPath = .init()
OR
@State private var navigationPath: [YourTabPath] = []
and configure your stack like this
NavigationStack(path: $navigationPath) { ... }
- Apply the following view modifiers to a tab’s
NavigationStack
:
.environment(\.navigationPathCount, navigationPath.count)
.environmentObject(navigation)
- On a tab’s root view inside its
NavigationStack
, apply the following view modifiers:
.tabBarNavigationEnabled(Tab.inbox, navigation)
.hoistNavigation()
For all destination views that can be pushed onto a NavigationStack
, the following setup is required:
- Apply the following view modifier to the top-level view
View { ... }
.hoistNavigation()
And that’s it for integrating TabStar
in a simple tabbed application. See below for examples on how to integrate TabStar
in some more complicated view configurations.
- Simply return
true
while there is an auxiliary action to be performed. Returnfalse
when a view should perform its dismiss action.
- If your app uses SwiftUI’s native
TabView
, you can safely useScrollViewReader
inside aNavigationStack
. - If you are using a custom tab view (e.g. some variation of
ZStack
), you may need to move theScrollViewReader
outside of theNavigationStack
. In this setup, you may also need to pass that scroll proxy to destination views via the environment, instead of declaring aScrollViewReader
for each view, as the latter may result in unexpected behaviour. Your mileage may vary.
- See the example app for a sample implementation, including how to show/hide the sidebar on iPad.
- You may need to put your “scroll-to-top” view inside a
LazyVStack
in order for.onAppear
/.onDisappear
to be called in the expected ways. Your mileage may vary.
In some instances, you may encounter an issue where the hoist dismiss action view modifier causes SwiftUI to enter an infinite loop when attempting to access the dismiss action from the environment. If so, explicitly define @Environment(\.dismiss)
in your view, and pass it into the view modifier.
- IMPORTANT: In this scenario, each view must define its own dismiss action. In other words, do not nest your destination views.
This isn't specific to TabStar
, but you may wish to use a custom navigation path over SwiftUI's NavigationPath
if you start seeing views pop off the navigation stack without animations. Your mileage may vary.
Feel free to contribute by opening an issue or submitting a pull request 🫶