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 solutionsTherefore, 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
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:
final class NavigationStack: ObservableObject {
@Published var viewStack: [NavigationItem] = []
@Published var currentView: NavigationItem
init(_ currentView: NavigationItem ){
self.currentView = currentView
}
}
3. Navigation methods: unwind and advance:
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:
func home( ){
currentView = NavigationItem( view: AnyView(HomeView()))
viewStack.removeAll()
}
5. The final NavigationStack class:
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
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