Creating a Custom Navigation in SwiftUI: Part 2 — Crafting the Custom Navigation Bar
In this second part of our series, we dive into the customization aspects of the custom navigation system designed for SwiftUI. We will explore various components such as _NavigationBarWrapper
, view extensions using preference keys, EquatableView
, ViewController
, and _SwiftUIView
. This detailed overview will showcase how these elements work together to provide a highly customizable navigation bar in SwiftUI.
NavigationBarWrapper: The Hub of Navigation Customization
Core Functionality: _NavigationBarWrapper
acts as a container for the content view, overlaying a navigation bar that can be adjusted according to the NavBarTitleConfiguration
. It’s designed to handle different layouts and states for the navigation bar, adapting to the specific needs of each screen in the app.
struct _NavigationBarWrapper<Content: View>: View {
// Determines if a back button should be shown in the navigation bar.
let showBackButton: Bool
// The destination content view that this navigation bar is associated with.
@ViewBuilder let destination: () -> Content
// Action to perform when the back button is tapped.
let onBackTapped: () -> Void
// Configuration for the navigation bar title.
@State private var navConfig = NavBarTitleConfiguration(title: "")
// Custom background view for the navigation bar.
@State private var backgroundView = EquatableView()
// Custom leading elements (e.g., buttons, icons) in the navigation bar.
@State private var leadingView = EquatableView()
// Custom trailing elements in the navigation bar.
@State private var trailingView = EquatableView()
// Allows for a custom navigation bar to be set.
@State private var customNavBar: EquatableView?
// Indicates whether the content is being scrolled.
@State private var isScrolling = false
// Controls the visibility of the navigation bar.
@State private var isNavBarHidden = false
var body: some View {
VStack(spacing: 0) {
// Check if the navigation bar is hidden and display content accordingly
if isNavBarHidden {
// Display custom navigation bar if it is hidden
customNavBar?.view
} else {
// Custom navigation bar component with configurable elements
_NavigationBar(
config: navConfig,
shouldShowBackButton: showBackButton,
background: backgroundView,
leadingElements: leadingView,
trailingElements: trailingView,
showDivier: isScrolling,
onBackTapped: onBackTapped)
}
// Main content view
destination()
.frame(maxHeight: .infinity)
}
// Handle preference changes for various navigation bar components
.onPreferenceChange(NavBarConfigPrefKey.self) { config in
self.navConfig = config
}
.onPreferenceChange(NavBarBackgroundPrefKey.self) { bg in
self.backgroundView = bg
}
.onPreferenceChange(NavBarLeadingPrefKey.self) { leadingView in
self.leadingView = leadingView
}
.onPreferenceChange(NavBarTrailingPrefKey.self) { trailingView in
self.trailingView = trailingView
}
.onPreferenceChange(NavBarScrollingPrefKey.self) { isScrolling in
withAnimation {
self.isScrolling = isScrolling
}
}
.onPreferenceChange(NavBarHiddenPrefKey.self) { isNavBarHidden in
self.isNavBarHidden = isNavBarHidden
}
.onPreferenceChange(CustomNavBarPrefKey.self) { navBar in
self.customNavBar = navBar
}
}
}
Dynamic Layout Control: It utilizes state variables like navConfig
for title configuration, shouldShowBackButton
for back button visibility. The wrapper ensures that these elements are cohesively integrated, resulting in a consistent and responsive navigation experience.
One of the unique challenges in customizing the SwiftUI navigation bar is passing view components up the view hierarchy while maintaining their state and identity. This is where EquatableView
becomes essential.
Why EquatableView? In SwiftUI, views aren’t inherently equatable, which means they can’t be compared for equality in a straightforward manner. However, when customizing a navigation bar, there’s often a need to pass views (like custom background views or icons for leading/trailing navigation items) through preference keys. Since preference keys require their values to be equatable for SwiftUI to detect changes and update the UI accordingly, we wrap our views in EquatableView
.
//MARK: - EquatableView
struct EquatableView: Equatable {
static func == (lhs: EquatableView, rhs: EquatableView) -> Bool {
return lhs.id == rhs.id
}
let id = UUID().uuidString
let view: AnyView
init(view: AnyView = AnyView(EmptyView())) {
self.view = view
}
}
Implementing View Extensions with Preference Keys
Customizing with Preference Keys: These extensions are pivotal for adding dynamic customization capabilities to the navigation bar in SwiftUI. By using preference keys, views and configurations are passed efficiently within the navigation structure.
public extension View {
/// Hides the navigation bar.
/// - Returns: A modified view with the navigation bar hidden.
func hideNavigationBar() -> some View {
self
.preference(key: NavBarHiddenPrefKey.self, value: true)
}
/// Sets the title, alignment, text color, and typography for the navigation bar.
/// - Parameters:
/// - title: The title of the navigation bar.
/// - alignment: The alignment of the title (default is `.leading`).
/// - textColor: The text color of the title (default is `.Brand.N900`).
/// - typography: The typography style of the title (default is `.heading_4`).
/// - Returns: A modified view with the specified navigation bar title configuration.
func navigationBarTitle(_ title: String, alignmet: Alignment = .leading, textColor: Color = .Brand.N900, typography: Typography = .heading_4) -> some View {
self
.preference(key: NavBarConfigPrefKey.self, value: .init(title: title, textColor: textColor, typography: typography, alignment: alignmet))
}
/// Sets the background view for the navigation bar.
/// - Parameter bg: A closure returning the background view.
/// - Returns: A modified view with the specified navigation bar background.
func navigationBarBackground<B: View>(@ViewBuilder bg: @escaping () -> B) -> some View {
self
.preference(key: NavBarBackgroundPrefKey.self, value: .init(view: AnyView(bg())))
}
/// Sets the leading item view for the navigation bar.
/// - Parameter content: A closure returning the leading item view.
/// - Returns: A modified view with the specified navigation bar leading item.
func navigationBarLeadingItem<L: View>(@ViewBuilder content: @escaping () -> L) -> some View {
self
.preference(key: NavBarLeadingPrefKey.self, value: .init(view: AnyView(content())))
}
/// Sets the trailing item view for the navigation bar.
/// - Parameter content: A closure returning the trailing item view.
/// - Returns: A modified view with the specified navigation bar trailing item.
func navigationBarTrailingItem<L: View>(@ViewBuilder content: @escaping () -> L) -> some View {
self
.preference(key: NavBarTrailingPrefKey.self, value: .init(view: AnyView(content())))
}
/// Sets both leading and trailing item views for the navigation bar.
/// - Parameters:
/// - leading: A closure returning the leading item view.
/// - trailing: A closure returning the trailing item view.
/// - Returns: A modified view with the specified navigation bar leading and trailing items.
func navigationBarItems<L: View, T: View>(@ViewBuilder leading: @escaping () -> L, @ViewBuilder trailing: @escaping () -> T) -> some View {
self
.preference(key: NavBarTrailingPrefKey.self, value: .init(view: AnyView(trailing())))
.preference(key: NavBarLeadingPrefKey.self, value: .init(view: AnyView(leading())))
}
/// Sets a custom content view for the navigation bar.
/// - Parameter content: A closure returning the custom content view.
/// - Returns: A modified view with the specified custom navigation bar content.
func navigationBar<Content: View>(@ViewBuilder _ content: @escaping () -> Content) -> some View {
self
.hideNavigationBar()
.preference(key: CustomNavBarPrefKey.self, value: .init(view: AnyView(content())))
}
/// Sets the scrolling status for the navigation bar.
/// - Parameter hasScrolled: A boolean indicating whether the view is currently scrolling.
/// - Returns: A modified view with the specified scrolling status.
func _hasScrolledDownward(_ hasScrolled: Bool) -> some View {
self
.preference(key: NavBarScrollingPrefKey.self, value: hasScrolled)
}
}
Real-World Examples: For instance, navigationBarTitle
dynamically sets the title, navigationBarBackground
changes the background view, and navigationBarLeadingItem
and navigationBarTrailingItem
extensions allow for adding custom leading and trailing elements.
Bridging SwiftUI with UIKit: ViewController and _SwiftUIView
UIViewController Extensions: These are critical for integrating UIKit-based view controllers with the SwiftUI navigation system. They allow traditional UIViewController
s to utilize the custom navigation features and ensure a seamless user experience across UIKit and SwiftUI components.
//MARK: - ViewControllerRepresentable
public struct ViewControllerRepresentable: UIViewControllerRepresentable {
let vc: UIViewController
public init(vc: UIViewController) {
self.vc = vc
}
public func makeUIViewController(context: Context) -> some UIViewController {
return vc
}
public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}
//MARK: - ViewController
@MainActor open class ViewController: UIViewController {
private let config = NavigationBarConfiguration()
/// Configuration for the navigation bar title. Updates will be published to `navBarConfigurationPublisher`.
final public var navBarTitleConfiguration = NavBarTitleConfiguration(title: "") {
didSet {
config.state.navConfig = navBarTitleConfiguration
}
}
/// Determines whether the navigation bar is hidden. Updates will be published to `navBarConfigurationPublisher`.
final public var isNavigationBarHidden = false {
didSet {
config.state.isNavBarHidden = isNavigationBarHidden
}
}
/// Sets the leading item of the navigation bar.
/// - Parameter content: A closure returning the leading item view.
final public func setNavBarLeadingItem<Content: View>(@ViewBuilder content: () -> Content) {
config.state.leadingItem = .init(content())
}
/// Sets the trailing item of the navigation bar.
/// - Parameter content: A closure returning the trailing item view.
final public func setNavBarTrailingItem<Content: View>(@ViewBuilder content: () -> Content) {
config.state.trailingView = .init(content())
}
/// Sets the background view for the navigation bar.
/// - Parameter content: A closure returning the background view.
final public func setNavBarBackground<Content: View>(@ViewBuilder content: () -> Content) {
config.state.navBarBackgroundView = .init(content())
}
/// Sets a custom view for the navigation bar.
/// - Parameter content: A closure returning the custom navigation bar view.
final public func setCustomNavBar<Content: View>(@ViewBuilder content: () -> Content) {
config.state.customNavBar = .init(content())
}
/// A SwiftUI view representation of the view controller.
@ViewBuilder final public func toSwiftUIView() -> some View {
_SwiftUIView(navConfig: config, vc: self)
}
}
SwiftUI Representation of UIKit Controllers: _SwiftUIView
represents a ViewController
in the SwiftUI environment. It enables the use of UIKit-based controllers within the SwiftUI navigation structure, maintaining the app's overall navigation consistency.
//MARK: - NavBarConfigurationState
struct NavBarConfigurationState {
var navConfig = NavBarTitleConfiguration(title: "")
var isNavBarHidden = false
var leadingItem: AnyView?
var trailingView: AnyView?
var navBarBackgroundView: AnyView?
var customNavBar: AnyView?
}
//MARK: - NavigationBarConfiguration
class NavigationBarConfiguration: ObservableObject {
@Published var state = NavBarConfigurationState()
}
//MARK: - _SwiftUIView
@MainActor
struct _SwiftUIView: View {
@ObservedObject var navConfig: NavigationBarConfiguration
let vc: ViewController
var body: some View {
return ViewControllerRepresentable(vc: vc)
.navigationBarTitle(config: navConfig.state.navConfig)
.isNavigationBarHidden(navConfig.state.isNavBarHidden)
.applyIf(navConfig.state.leadingItem != nil) {
$0
.navigationBarLeadingItem(content: navConfig.state.leadingItem!)
}
.applyIf(navConfig.state.trailingView != nil) {
$0
.navigationBarTrailingItem(content: navConfig.state.trailingView!)
}
.applyIf(navConfig.state.navBarBackgroundView != nil) {
$0
.navigationBarBackground(bg: navConfig.state.navBarBackgroundView!)
}
.applyIf(navConfig.state.customNavBar != nil) {
$0
.navigationBar(content: navConfig.state.customNavBar!)
}
}
}
Conclusion
This part of the series aims to provide a comprehensive understanding of the components and techniques used to customize the navigation bar in SwiftUI. By exploring each element in detail, we offer insights into how developers can leverage these tools for their specific UI and UX needs.
What’s Next: Part 3 — Unveiling the Core Navigation Logic
The upcoming part will focus on the core logic behind this custom navigation system. We’ll explore the inner workings of the Navigator
and the integration with UINavigationController
, providing a deep understanding of how these components manage the navigation flow in a SwiftUI application, especially in contexts that also involve UIKit components.
👉 Catch up on the series:
Part 1 :- The Foundation
Part: 3 :- Unveiling the Core Navigation Logic
Part 4 :- Putting It All Together: Real-World Navigation Scenarios