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
NavigationStackvia 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 useNavigationPathor 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.
-
TabStarhelps by relying on SwiftUI’s@Environment(\.dismiss)action to perform programmatic dismissal. ThatDismissActionis 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
truewhile there is an auxiliary action to be performed. Returnfalsewhen a view should perform its dismiss action.
- If your app uses SwiftUI’s native
TabView, you can safely useScrollViewReaderinside aNavigationStack. - If you are using a custom tab view (e.g. some variation of
ZStack), you may need to move theScrollViewReaderoutside of theNavigationStack. In this setup, you may also need to pass that scroll proxy to destination views via the environment, instead of declaring aScrollViewReaderfor 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
LazyVStackin order for.onAppear/.onDisappearto 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 🫶