SwiftUI: Custom navigation

SwiftUI Custom navigation component motivation:
Building a custom navigation component for SwiftUI is a useful addition to SwiftUI community. After the release of SwiftUI, until now (beta 5 of xcode 11), one of the pain points experienced by the Swift developer was the customisation of the navigation bar. Precisely, changing the colour of the bar, the colour or font of the text, the look of the back button seem to be impossible or quite cumbersome tasks. Also navigating programatically is something that you cannot do with the out-of-the-box API provided by Apple.
I have seen solutions involving changing the global appearance of the navigation bar:

 UINavigationBar.appearance().backgroundColor = .green  

or solutions trying to open the destination of the navigation as modal; however none of those solutions are entirely satisfying my needs while developing an app. They feel like workarounds, rather than fully trustable solutions
Therefore, I was thinking about a custom made navigation framework; that should be easy to use and would provide the following functionality:
  • ability to navigate forwards or backwards in the navigation stack, both programatically and using the back button
  • potential to customise all the characteristics of navigation bar, including colours, fonts, text and button images.
  • ability to perform custom logic before or after transitioning to next/previous screen
As a result of my efforts, here is the solution I came with. I am using it in a package list application that I am currently developing entirely in SwiftUI. So far I have very smooth navigation with 4-5 items in the navigation stack at times, advancing forwards and backwards easily, according to the logic of my application.
1. Representing the view:
I declare a simple type, NavigationItem, encapsulating an AnyView. Erasing the type of View, I am able to store different types of View objects, in a generic way; otherwise this would be impossible for a protocol with associated types.

 struct NavigationItem{     
      var view: AnyView   
 }  

2. Introducing the NavigationStack:
Implement a navigation stack observable object that will provide published properties for current View (the view that is visible in the stack at the current moment) and the navigation stack. This stack contains the previous views that were shown so far, in order to provide the classical back functionality:

 final class NavigationStack: ObservableObject {    
      @Published var viewStack: [NavigationItem] = []    
      @Published var currentView: NavigationItem    
      init(_ currentView: NavigationItem ){      
           self.currentView = currentView    
      }  
 }  

3. Navigation methods: unwind and advance:
Here are the methods that provide ability to navigate in the stack

 func unwind(){      
      if viewStack.count == 0{        
           return      
      }      
      let last = viewStack.count - 1      
      currentView = viewStack[last]      
      viewStack.remove(at: last)    
 }    
 func advance(_ view:NavigationItem){      
      viewStack.append( currentView)      
      currentView = view    
 }  

4. Home method as bonus:
As in the case of the app I am developing I am having the concept of a home screen, that I can return to at any time, I have implemented as well a home method; in this case I am showing the HomeView. Additionally all the navigation history is forgotten by erasing all the elements from the navigation stack.

 func home( ){     
      currentView = NavigationItem( view: AnyView(HomeView()))     
      viewStack.removeAll()  
 }   

5. The final NavigationStack class:
Here is a listing of the final navigation class, putting together all the items I talked so far:

 final class NavigationStack: ObservableObject {    
      @Published var viewStack: [NavigationItem] = []    
      @Published var currentView: NavigationItem    
      init(_ currentView: NavigationItem ){      
           self.currentView = currentView    
      }    
      func unwind(){      
           if viewStack.count == 0{        
                return      
           }      
           let last = viewStack.count - 1      
           currentView = viewStack[last]      
           viewStack.remove(at: last)    
      }    
      func advance(_ view:NavigationItem){      
           viewStack.append( currentView)      
           currentView = view    
      }      
      func home( ){      
           currentView = NavigationItem( view: AnyView(HomeView()))      
           viewStack.removeAll()    
      }  
 }  

6. Now lets observe the NavigationStack and react on it:
In order to observe the Navigation Stack properties and react to them, I need placeholder view that will react on changes of the currentView, displaying it. The NavigationStack is injected as EnvironmentObject into the view, therefore available for this view and all its child views.

 struct NavigationHost: View{    
      @EnvironmentObject var navigation: NavigationStack    
      var body: some View {      
           self.navigation.currentView.view    
      }  
 }  

7. Some predefined BackView:
Before exploring the usage of this navigation functionality, I would like to provide as well a simple BackView class; it can be used to show the title of the current view , a back button to the previous view in navigation stack and a home button to return to the HomeView. You can give Home button a miss if you are not interested in this functionality:

 struct BackView: View{    
      var title: String    
      var action: ()->Void    
      var homeAction: ()->Void    
      var body: some View {      
           ZStack{        
                Rectangle().fill(Color.gray).frame( height: 40 )        
                HStack{          
                     Button( action: action){     
                          Image(uiImage: UIImage(systemName: "arrow.turn.down.left",   
                                withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .bold, scale: .large))! )            
                          .padding(.leading, 20)          
                     }.foregroundColor(Color.black)                  
                     Spacer()    
                     Text(title).padding(.leading, 20).font(Font.system(size: 20))  
                          .padding(.trailing, 20)          
                     Spacer()          
                     Button( action: homeAction){     
                          Image(uiImage: UIImage(systemName: "house",   
                                withConfiguration: UIImage.SymbolConfiguration(pointSize: 15.0, weight: .bold, scale: .large))! )            
                                .padding(.trailing, 20)          
                     }.foregroundColor(Color.black)        
                }      
           }    
      }  
 }  

8. And a simple TitleView as bonus:
I created also a simple title view (only containing a title and home button) for the case when no Back button needs to be displayed,

 struct TitleView: View{    
      var title: String      
      var homeAction: ()->Void       
      var body: some View {      
           ZStack{        
                Rectangle().fill(Color.gray).frame( height: 40 )        
                HStack{          
                     Spacer()    
                     Text(title).padding(.leading, 20).font(Font.system(size: 20.0))          
                     Spacer()          
                     Button( action: homeAction){            
                          Image(uiImage: UIImage(systemName: "house",   
                                withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .bold, scale: .large))! )            
                                .padding(.trailing, 20)          
                     }.foregroundColor(Color.black)        
                }      
           }    
      }  
 }  

9. Lets put things together:
Now we have all the building blocks necessary to build a simple app for exemplifying the usage of the above elements. Lets start with the HomeView, the starting screen in the navigation stack. Nothing really fancy here, just a Title at the top of the screen, and a Text showing ‘Move to NextView’ message. On tapping this Text, another View is pushed into the NavigationStack

 struct HomeView: View {    
      @EnvironmentObject var navigation: NavigationStack     
      var body: some View {   
           VStack{      
                TitleView( title: "Home view", homeAction: {            
                     self.navigation.home()          
                })      
                List{        
                     Text("Move to NextView").onTapGesture {    
                          self.navigation.advance( NavigationItem( view: AnyView(NextView())))     
                     }      
                }   
           }     
      }  
 }  

10. The NextView:
The NextView is the view we land from HomeView, by tapping the Text. It displays a BackView; pressing the back button will return to the HomeView. It also shows the Home button, which can be used to go to HomeView (of course if the stack would have more elements, Back button action and Home button action will not have the same effect)

 struct NextView: View {    
      @EnvironmentObject var navigation: NavigationStack     
      var body: some View {       
           VStack{     
                BackView( title: "I am Next View", action:{       
                     self.navigation.unwind()         
                }, homeAction: {           
                     self.navigation.home()         
                })     
                List{       
                     Text("I am NextView")     
                }   
           }    
      }  
 }  

11. And in the end, the entry point in the application:
A very simple implementation of ContentView, embedding a NavigationHost view with a NavigationStack as environment object

 struct ContentView: View {    
      var body: some View {      
           NavigationHost()        
                .environmentObject(NavigationStack( NavigationItem( view: AnyView(HomeView()))))    
      }  
 }  

Here is how the 2 screens of this simple app are looking like:

Conclusion:
The solution I provided here is an alternative to the SwiftUI NavigationView. It solves a plethora of problems that NavigationView is suffering from, giving absolute control to the developer, to achieve the results that he or she is after:
  • It gives a lot of flexibility in the visual aspect of navigation elements. You can place them in the exact way you want, at the top or bottom of your screen, or even as a round button in the middle if thats what you are looking for. Just implement a new BackView object and include it in your views in the way you want. Or even more, you can have different back views in each of your view; you can have the navigation bar taking the colour as the view; or you can simply assign different size, font or back button image for navigation bar in each view.
  • It gives ability to call all this programatically. You can unwind the stack in a callback after performing a particular operation in the current view. But you can also have regular back button navigation.
  • It opens the door for non-linear navigation. In case you want, you can implement really weird navigation path in your application and with a bit of tweaking of the NavigationStack class, everything can be achived

Comments

  1. Hello. I followed the crumb trail here from Medium. It surprises me that this article is still relevant today. In any case, someone asked about adding animated transitions to this implementation. I tried figuring it out, but I've been running into a wall. Do you have any suggestions?

    ReplyDelete

Post a Comment

Popular posts from this blog

SwiftUI: handling images in Dark theme

SwiftUI - interacting with UIKit - part1: map view