SwiftUi: modal views to the custom navigation framework

After presenting a way to implement custom navigation views in a previous post, now it is time to add the support for modal views. As a demonstration of the concept, I will be showing an ImagePicker modally, using this technique.
In a previous post, I have presented a simple framework for achieving navigation, covering some features that lack in the current navigation view of SwiftUI; namely visual customisation and ability to push views or unwind the stack programatically. During this post, I will be exploring even further, adding support for modal views.
So to recap, this is how the NavigationStack and NavigationHost classes were looking like at the end of the previous post:

 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()    
      }  
 }  

1. Modal to NavigationStack
I add a new optional member to NavigationStack, so that the future modal view can be represented:

 @Published var currentModalView: NavigationItem? = nil  

2. show/hide the modal
I add support for the modal views in the NavigationStack class by adding 2 methods for showing and hiding the modal view:

 func modal(_ view:NavigationItem){     
      currentModalView = view   
 }   
 func hideModal(){     
      currentModalView = nil   
 }  

3. Specifying the size and the position
I add 2 enumerations to control the size and the position of the modal view:

 enum ModalPositioning{   
      case top   
      case center   
      case   
      bottom  
 }  
 enum ModalSize{   
      case full   
      case s3_4   
      case s1_2   
      case s1_4  
 }  

4. ModalView container
I create a simple ModalView container, that can be used to show your view as modal. It is supporting all the combination of size and positioning defined in the enumerations above

 struct ModalView<T:View>: View{   
      @EnvironmentObject var navigation: NavigationStack   
      var embedded: T   
      var positioning: ModalPositioning   
      var size: ModalSize = .s1_2   
      func sizeToHeight()->CGFloat{   
           var factor : CGFloat = 1   
           var reduce: CGFloat = 0   
           switch size{   
                case .full:    
                     factor = 1    
                     reduce = 80   
                case .s1_2:    
                     factor = 0.5   
                case .s1_4:    
                     factor = 0.25   
                case .s3_4:    
                     factor = 0.75   
           }   
           return factor * UIScreen.main.bounds.height - reduce   
      }   
      var body: some View{   
           GeometryReader{ geometry in    
                VStack{    
                     if self.positioning == .bottom && self.size != .full{     
                          Spacer()    
                     }    
                     self.embedded.frame(width: geometry.size.width, height: self.sizeToHeight())    
                          .background(RoundedRectangle(cornerRadius: 20)  
                                              .fill(Color.gray))       
                     if self.positioning == .top && self.size != .full{     
                          Spacer()    
                     }    
                }   
           }   
      }  
 }   

5. most importantly, show the modal !
I change the NavigationHost to show the modal, if defined, on top of the current view:

 struct NavigationHost: View{    
      @EnvironmentObject var navigation: NavigationStack    
      var body: some View {   
           ZStack(alignment: .bottom){    
                self.navigation.currentView.view     
                if navigation.currentModalView != nil{    
                     navigation.currentModalView!.view      
                }   
           }    
      }  
 }  

6. Usage of modal
Let’s explore the usage of this modal view. I create a simple view that can be used as modal:

 struct SimpleModalView: View{   
      @EnvironmentObject var navigation: NavigationStack   
      var body: some View{   
           VStack{    
                Text("I am a modal")    
                Button(action:{    
                     self.navigation.hideModal()    
                }){    
                     Text("Hide me")    
                }   
           }   
      }  
 }  

7. Launch the modals from NextView
Change the NextView from previous post : 3 new buttons are displayed, with the texts “Show modal top”, “Show modal center” and respectively “Show modal bottom”. If pressed, they will launch a modal in the position indicated by the title; the modal will have default size of 1/2 of screen size. The first button also performs the modal presentation in an animated way (easeIn during half a second )

 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")    
                     Button(action:{     
                          withAnimation(.easeIn(duration: 0.5)) {     
                               self.navigation.modal(NavigationItem( view:   
                                    AnyView(ModalView(embedded: SimpleModalView(), positioning: .top))))     
                          }    
                     }){     
                          Text("Show modal top")    
                     }.background(Color.orange)    
                     Button(action:{     
                          self.navigation.modal(NavigationItem( view:   
                               AnyView(ModalView(embedded: SimpleModalView(), positioning: .center))))    
                     }){     
                          Text("Show modal center")    
                     }.background(Color.orange)    
                     Button(action:{     
                          self.navigation.modal(NavigationItem( view:   
                               AnyView(ModalView(embedded: SimpleModalView(), positioning: .bottom))))    
                     }){     
                          Text("Show modal bottom")    
                     }.background(Color.orange)    
                }   
           }      
      }  
 }  

Here are a few screenshots showing the modal. Of course, everything can be customised, so different sizes, positioning or colours can be used. Also one thing that I haven’t included in this implementation is to deactivate the actions of the view located underneath the modal.





Lets try something a bit more complicated, and try to show an ImagePicker view as a modal. I will not cover the basics of wrapping a ViewController in SwiftUI, this is explained fairly well in Apple documentation. I would like to show some example on this topic in a future post.
8. Wrap UIImagePickerController
I wrap the UIImagePickerController in a UIViewControllerRepresentable. I am passing 2 elements into this: an UIImage for transmitting the image out of the picker and a hide callback, for hiding the view after selecting an image from library in addition to cancelling the picker.

 struct ImagePicker: UIViewControllerRepresentable {    
      @Binding var uiImage: UIImage?    
      var hide:()->Void   
      @EnvironmentObject var navigation: NavigationStack    
      class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {      
           @Binding var uiImage: UIImage?   
           var hide:()->Void   
           init( uiImage: Binding<UIImage?>, hide:@escaping ()->Void) {        
                _uiImage = uiImage    
                self.hide = hide      
           }      
           func imagePickerController(_ picker: UIImagePickerController,   
                                              didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {        
                let imagePicked = info[UIImagePickerController.InfoKey.originalImage] as! UIImage        
                uiImage = imagePicked    
                hide()      
           }      
           func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {        
                hide()      
           }    
      }    
      func makeCoordinator() -> Coordinator {   
           return Coordinator(uiImage: $uiImage, hide: hide)    
      }    
      func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>)   
           -> UIImagePickerController {      
           let picker = UIImagePickerController()      
           picker.delegate = context.coordinator      
           return picker    
      }    
      func updateUIViewController(_ uiViewController: UIImagePickerController,   
           context: UIViewControllerRepresentableContext<ImagePicker>) {   
      }  
 }  

9. View to launch the imagePicker and after that, display the selected image
Make a view that shows the selected image (if any) and a small button for invoking the ImagePicker:

 struct LibraryImage: View {    
      @State var uiImage: UIImage? = nil   
      @EnvironmentObject var navigation: NavigationStack   
      var launchPicker: some View{   
           Image(systemName: "camera.on.rectangle")   
                .frame(width: 100, height: 100)   
                .onTapGesture {    
                     self.navigation.modal(NavigationItem( view: AnyView(ModalView(embedded: ImagePicker(uiImage: self.$uiImage,   
                          hide:{    
                               self.navigation.hideModal()    
                          }  
                     ),   
                     positioning: .bottom, size: .full))))   
                }   
      }    
      var body: some View {      
           VStack {        
                if (uiImage == nil) {          
                     launchPicker        
                }   
                else {   
                     VStack{    
                          Image(uiImage: uiImage!)    
                               .resizable()    
                               .aspectRatio(contentMode: .fit)    
                               .frame(width: 300)    
                               .cornerRadius(6)    
                          launchPicker   
                     }        
                }      
           }    
      }  
 }   

10. The ContentView utilising the code from above
Show the LibraryImage view inside the NavigationHost:

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

And finally, this is how this example looks on simulator:




I hope you enjoyed this tutorial and you got a better understanding on how to show various views in either navigation or modal way. The elements presented are still a bit rough around the corners; however, they can be extended and improved based on the needs of the application. Above all, I think they can be a good starting point for extending the already rich toolset provided by SwiftUI with few more options. As a result, you can have a broader set of possibilities for building your application.

Comments

Popular posts from this blog

SwiftUI: Custom navigation

SwiftUI UIKit – part3: push data to UIViewController

SwiftUI: handling images in Dark theme