Code an animated popup SwiftUI View presented by a UIViewController - Swift 5 & Xcode 11

Code an animated popup SwiftUI View presented by a UIViewController - Swift 5 & Xcode 11

Overview

This article will walk you through, from start to finish, creating a popup UIViewController with a mix of Storyboards and SwiftUI. This method for presenting a UIViewController is not ideal in all use cases but it can be very useful. The main use of this system is to integrate SwiftUI Views into an existing UIKit project. No coding experience is necessary to follow along and create this app, but the code will reach an intermediate to advanced level and will not be explained in full.

Here is a quick road map for what we will be doing in the tutorial sections.

  1. Create a new project
  2. Create a custom UIButton
  3. Add a target for the custom UIButton that will create a popup
  4. Create a new UIViewController (ViewController2)
  5. Present a SwiftUI View inside of ViewController2 using a UIHostingController
  6. Present ViewController2 inside of ViewController
  7. Add animations to ViewController2
  8. Make it possible to remove the popup

We will not explain the following in this tutorial, but the code will be supplied:

  1. Create custom SwiftUI View's
  2. Create NSNotification listeners to pass data about the state of our popup

At the end you will have coded this app using no interface builders, but both SwiftUI and UIKit:

Code

To begin, create a new single view app. It is important that you select Storyboard as the UI. Our app will be presenting a SwiftUI View inside of a UIHostingController so selecting Storyboard now will make it significantly easier to begin from a UIViewController. If you have an existing UIKit app to implement these concepts within, you will likely have a storyboard and UIViewControllers already.

Note: We won't use the storyboard for any part of this tutorial, everything will be done with code. At the end I will include the entirety of the code in a copyable snippet for testing.

No alt text provided for this image


You will see a few files created by Xcode. We will need a user interface to work from. Open ViewController.swift to begin creating your UI. We need to code a UIButton that we can tap to open our popup.

Start by declaring a method called createUI( ). This method will create a UIButton and change the background color

//We will create our UI in here

    func createUI(){

        //start by declaring a UIButton() variable.
        //next give it a frame 
        let button = UIButton()
        button.frame = UIScreen.main.bounds
        

        //this code is optional
        button.setTitle("POP", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: UIScreen.main.bounds.width/8)
        self.view.backgroundColor = UIColor.systemYellow



        //Add the button to our View so we can interact with it
        self.view.addSubview(button)

}

Call the function in viewDidLoad( )

    override func viewDidLoad() {
        super.viewDidLoad()
        createUI()

    }

If you build and run the app you should see the following:

No alt text provided for this image

As of now, our button does not do anything, but now we know our app runs and looks pretty. We need to go back into the createUI( ) method and add a target for our button. To do this we will create a target function, and then use the .addTarget method on our button before we add it as a subview.

//We will create our UI in here

    func createUI(){

        //keep code from before



        //this code adds a target for our button 
        button.addTarget(self, action: #selector(pressed), for: .touchUpInside)



        //Add the button to our View so we can interact with it
        self.view.addSubview(button)

}  


//target method
    @objc func pressed(sender: UIButton!) {
        print("Hello")
    } 

At this point if you build and run the app, tapping the button will print "Hello" to console. Let's skip ahead a few steps to quickly see how we will present the SwiftUI elements. Add import SwiftUI to the top of the file, right after import UIKit.

import UIKit
import SwiftUI

Next, in the pressed method, add the following:

    @objc func pressed(sender: UIButton!) {
        let popOverVC = UIHostingController(rootView: Rectangle().foregroundColor(Color(UIColor.tertiarySystemFill)))
        self.addChild(popOverVC)
        let frame = CGRect(x: UIScreen.main.bounds.width/4, y: UIScreen.main.bounds.height/4, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2 )
        popOverVC.view.frame = frame
        self.view.addSubview(popOverVC.view)
        popOverVC.didMove(toParent: self)
    }

Build and run the app and after tapping "POP" you should see the following:

No alt text provided for this image

The code will make sense later on, but for now you can officially say your app is running a SwiftUI View and UIViewController at the same time. At this stage in our code, it is impossible to get rid of the popup, so delete the code from the pressed method in preparation of our next steps.

@objc func pressed(sender: UIButton!) {
 
}


At this point, your code should look like this:

No alt text provided for this image

When we ran the app, we had no way of closing the popup and it was not much of a popup, it simply appeared on screen. In this next part, both of these issues will be addressed. To begin, declare a new class ViewController2 : UIViewController { } and add the viewDidLoad( ) override and super init. You can declare this class right under the ViewController class, or create a new file.

class ViewController2: UIViewController{

       
  override func viewDidLoad() {

        super.viewDidLoad()

  }

}

This second UIViewController will act as a nexus between our SwiftUI View and our original ViewController. We will present ViewController2 from ViewController and then present our SwiftUI view inside of a UIHostingController from ViewController2.

That was garbled, so let's break it apart. ViewController is our first class. It has a button and a yellow background. It runs when we start the app. Our goal is to have a SwiftUI View pop on top of this yellow screen. We directly placed a SwiftUI View on top of ViewController earlier in the form of a light gray box, but we could not remove it and it was not animated. To animate the transition and make it possible to remove the popup, we need to enclose the popup within a UIViewController that can more easily transfer data back to the original ViewController.

No alt text provided for this image

As you can see ViewController sits at the root, with ViewController and then finally SwiftUI View ascending.

No alt text provided for this image

Similar to ViewController( ), ViewController2( ) will need a method that handles the UI, but in this case our UI will simply be a SwiftUI View. Create a loadSwiftUIView( ) method with the following code and then call it within viewDidLoad.

    override func viewDidLoad() {
       super.viewDidLoad()
       loadSwiftUIView()
}
    func loadSwiftUIView(){

       let popOverVC = UIHostingController(rootView: Rectangle().foregroundColor(Color(UIColor.tertiarySystemFill)))
       self.addChild(popOverVC)
       popOverVC.view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2)
       self.view.addSubview(popOverVC.view)
       popOverVC.didMove(toParent: self)
}

This code should look familiar. It is almost the same code that we created the light gray rectangle with. There is a lot happening in this method. To start, popOverVC, aUIHostingController is initialized with a rootView of a Rectangle( ). Rectangle( ) is a placeholder for now until we have a proper SwiftUI View. Next popOverVC is added within ViewController2. It needs dimensions, so we declare its frame. Next we add popOverVC.view as a subview and finally we move popOverVC to ViewController2.

Visually, if you return to the diagram a few paragraphs back, the mint colored box is now inside the salmon colored box, but the salmon colored box still is not inside the blue colored box. This means that if you build and run the app, nothing will happen yet! Go back to the pressed method in ViewController( ) and add the following code so we can see some results.

    @objc func pressed(sender: UIButton!) {
        let popOverVC = ViewController2()
        self.addChild(popOverVC)
        let frame = CGRect(x: UIScreen.main.bounds.width/4, y: UIScreen.main.bounds.height/4, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2 )
        popOverVC.view.frame = frame
        self.view.addSubview(popOverVC.view)
        popOverVC.didMove(toParent: self)

    }


Familiar yet? I won't dive into an explanation of everything going on here, since we just went over it but I will point out some of the differences. The differences here are in the frame x, frame y, and the popOverVC type. In this method we want to add ViewController2 to ViewController, so instead of a UIHostingController, we simply declare ViewController2( ). If you build and run the app again you will find that once more a light gray box appears when you tap "POP". The light gray box is still not animated but we will take care of that now.

No alt text provided for this image


There are many ways of accomplishing an animation, but this method is an easy drop in for the purpose of this tutorial. Add the following method to your ViewController2( ) class beneath the loadSwiftUIView( ) method.

    func showAnimate(){

        //moves into frame from an alpha of 0 so that it is hidden
        self.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
        self.view.alpha = 0.0;
        UIView.animate(withDuration: 0.25, animations: {

        //starts at 0 and moves to 1 such that the view comes into the view
          self.view.alpha = 1.0
          self.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)

        });

    }

Next add the showAnimate( ) method into viewDidLoad.

   override func viewDidLoad() {

        super.viewDidLoad()
        loadSwiftUIView()

        showAnimate()
   }

Build and run the app and tap on the button. A light gray box pops up! At this point you can create more light gray boxes (bug fixed at the end). This is because our original "POP" button fills the screen and our taps are passing outside of the presented ViewController2( ) and Rectangle( ).

We still need to be able to remove the popup, so let's handle that next. Like showAnimate( ) from before, we need a method that removes ViewController2 with an animation. Add the following method below showAnimate( ):

    func removeAnimate(failure: Bool){
        if failure {
        self.view.endEditing(true)
        UIView.animate(withDuration: 0.25, animations: {
            self.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
       //starts at 1.0 and goes to 0.0 such that the view becomes hidden
            self.view.alpha = 0.0;
        }, completion:{(finished : Bool)  in
            if (finished){
       //waits for the full animation, and then fully removes the View
                self.view.removeFromSuperview()
                self.removeFromParent()
            }
        })
        }

    }

You may animate the view in a different way, however make sure to include these two lines after your animation completes:

self.view.removeFromSuperview()
self.removeFromParent()

At this point we have no way to access this method. In order to test our code, we will convert the light gray Rectangle( ) into a Button( ) and call the remove animate method from the Button( ). Return to the loadSwiftUIView( ) method and replace:

let popOverVC = UIHostingController(
rootView: Rectangle().foregroundColor(Color(UIColor.tertiarySystemFill)))

with:

let popOverVC = UIHostingController(
rootView: Button(action: {
                 self.removeAnimate(failure: true)
                }) {
                    Text("close")
                        .foregroundColor(.yellow)

                })

Build and run the app and you should be able open and close popups! We have officially accomplished displaying an animated popup SwiftUI View on a UIViewController and it can be closed.

Here is all of the code so far:

import UIKit

import SwiftUI



class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        createUI()
    }

    
    //We will create our UI here

    func createUI(){
        //start by declaring a UIButton() variable
        let button = UIButton()
        //this code is optional
        button.setTitle("POP", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: UIScreen.main.bounds.width/8)
        button.frame = UIScreen.main.bounds
        self.view.backgroundColor = UIColor.systemYellow
        //this code adds a target for our button
        button.addTarget(self, action: #selector(pressed), for: .touchUpInside)
        self.view.addSubview(button)
    }

    

    @objc func pressed(sender: UIButton!) {
        let popOverVC = ViewController2()
        self.addChild(popOverVC)
        let frame = CGRect(x: UIScreen.main.bounds.width/4, y: UIScreen.main.bounds.height/4, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2 )
        popOverVC.view.frame = frame
        self.view.addSubview(popOverVC.view)
        popOverVC.didMove(toParent: self)
    }
}




class ViewController2: UIViewController{

    override func viewDidLoad() {
        super.viewDidLoad()
        loadSwiftUIView()
        showAnimate()
    }




    func loadSwiftUIView(){
        let popOverVC = UIHostingController(rootView: Button(action: {
            self.removeAnimate(failure: true)
        }) {
            Text("close")
                .foregroundColor(.yellow)
        })

        self.addChild(popOverVC)
        popOverVC.view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2 )
        self.view.addSubview(popOverVC.view)
        popOverVC.didMove(toParent: self)
        PopManager().pop()
    }

    func showAnimate(){
        //moves into frame from an alpha of 0 so that it is hidden
        self.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
        self.view.alpha = 0.0;
        UIView.animate(withDuration: 0.25, animations: {
       //starts at 0 and moves to 1 such that the view comes into the view
            self.view.alpha = 1.0
            self.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
        });
    }

    //removes the view

    func removeAnimate(failure: Bool){
        print("removeAnimate()")
        if failure {
            self.view.endEditing(true)
            UIView.animate(withDuration: 0.25, animations: {
                self.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
       //starts at 1.0 and goes to 0.0 such that the view becomes hidden
                self.view.alpha = 0.0;

            }, completion:{(finished : Bool)  in
                if (finished){
       //waits for the full animation, and then fully removes the View
                    self.view.removeFromSuperview()
                    self.removeFromParent()
                }
            });
        }
    }
    

}

It is important to recognize that while we do have a working popup, the popup is a simple box and button. Everything prior to this point can be considered relatively basic, and everything from here onward could be considered intermediate to advanced Swift.

The tutorial elements of this article are now over, and individual supplementary tutorial elements will be included later and linked elsewhere. We achieved what we set out to do at this point, but it might be interesting to see a bit more of the potential SwiftUI is offering. Feel free to follow along and test the code by copying from the following snippets. I hope to publish more articles which explain the following concepts, but for now I will simply include the content.

No alt text provided for this image

Add this code outside of a class. In extreme brevity, it creates listeners to pass data throughout our app using NSNotificationCenter.

import UIKit




struct PopStateS {

    static var state = PopManager.PopState.idle {
        didSet { PopManager().stateDidChange() }
       }
}




class PopManager {

    enum PopState {
        case up
        case down
        case idle
    }

    private let notificationCenter: NotificationCenter

     init(notificationCenter: NotificationCenter = .default) {
         self.notificationCenter = notificationCenter
     }

    func pop() {
        PopStateS.state = .up
    }

    func down() {
        switch PopStateS.state {
        case .idle:
            PopStateS.state = .down
        case .down:
            break
        case .up:
            PopStateS.state = .down
        }
    }

    func idle() {
        PopStateS.state = .idle
    }

    func stateDidChange() {

        switch PopStateS.state {
        case .up:
            notificationCenter.post(name: .popStopped, object: nil)
        case .down:
            notificationCenter.post(name: .popStarted, object: nil)
        case .idle:
            notificationCenter.post(name: .popPaused, object: nil)
        }
    }
}




extension Notification.Name {

    static var popStarted: Notification.Name {
        return .init(rawValue: "PopManager.popStarted")
    }

    static var popPaused: Notification.Name {
        return .init(rawValue: "PopManager.popPaused")

    }

    static var popStopped: Notification.Name {
        return .init(rawValue: "PopManager.popStopped")
    }

}

Next, add this code outside of a class. It will be our custom SwiftUI View.

import SwiftUI




protocol colorNav {

    var colorSelection: Int? { get set }

}

struct ContentView: View {

    let edgeyInsets = EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)

    @State var rTarget = Double.random(in: 0..<1)
    @State var gTarget = Double.random(in: 0..<1)
    @State var bTarget = Double.random(in: 0..<1)

    
    @State var rGuess: Double
    @State var gGuess: Double
    @State var bGuess: Double
    @State var showAlert = false


    func computeScore() -> Int {

      let rDiff = rGuess - rTarget
      let gDiff = gGuess - gTarget
      let bDiff = bGuess - bTarget
      let diff = sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff)
      return Int((1.0 - diff) * 100.0 + 0.5)
    }

    

    var body: some View {
        ZStack {
            Color.white
            .edgesIgnoringSafeArea(.all)
            VStack {
                HStack {
                    // Target color block
                    VStack {
                        Rectangle()
                            .foregroundColor(Color(red: rTarget, green: gTarget, blue: bTarget, opacity: 1.0))
                        Text("Match this color")
                    }

                    VStack {
                        Rectangle()
                            .foregroundColor(Color(red: rGuess, green: gGuess, blue: bGuess, opacity: 1.0))
                            .edgesIgnoringSafeArea(.top)
                        HStack {
                            Text("R: \(Int(rGuess * 255.0))")
                            Text("G: \(Int(gGuess * 255.0))")
                            Text("B: \(Int(bGuess * 255.0))")
                        }   
                    }
                }.padding(edgeyInsets)

                

                VStack {
                    HStack {
                        Button(action: {
                            self.rTarget = Double.random(in: 0..<1)
                            self.gTarget = Double.random(in: 0..<1)
                            self.bTarget = Double.random(in: 0..<1)
                        }) {
                            Text("Reset!")
                                .foregroundColor(.gray)
                        }.padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 50))

                        Button(action: {
                            self.showAlert = true
                        }) {
                            Text("Check!")
                                .foregroundColor(.blue)
                                .bold()
                        }
                        .alert(isPresented: $showAlert) {
                          Alert(title: Text("Your Score"), message: Text("\(computeScore())"))
                        }
                    }
                    ColorSlider(value: $rGuess, textColor: .red)
                    ColorSlider(value: $gGuess, textColor: .green)
                    ColorSlider(value: $bGuess, textColor: .blue)
                }

                HStack {
                    Button(action: {
                       PopManager().down()
                    }) {
                        Text("close")
                            .foregroundColor(.gray)
                    }.padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 50))
                }
            }   
        }
    }

}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(rGuess: 0.5, gGuess: 0.5, bGuess: 0.5)
    }
}




struct ColorSlider: View {
    @Binding var value: Double
    var textColor: Color
    let edgeyInsets = EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)
    var body: some View {
        HStack {
            Text("0").foregroundColor(.red)
            Slider(value: $value, in: 0.0...1.0)
            Text("255").foregroundColor(textColor)
        }.padding(edgeyInsets)

    }

}


Finally, update the ViewController( ) and ViewController2( ) classes to look like this:

import UIKit

import SwiftUI




class ViewController: UIViewController {

    

    override func viewDidLoad() {

        super.viewDidLoad()
        createUI()
    }

    

    func createUI(){

        let button = UIButton()
        button.setTitle("POP", for: .normal)
        button.titleLabel?.font = UIFont.systemFont(ofSize: UIScreen.main.bounds.width/8)
        button.frame = UIScreen.main.bounds
        self.view.backgroundColor = UIColor.systemYellow
        button.addTarget(self, action: #selector(pressed), for: .touchUpInside)
        self.view.addSubview(button)

    }

    

    @objc func pressed(sender: UIButton!) {

        switch PopStateS.state {
        case .up:
            PopManager().down()
        default:
            let popOverVC = ViewController2()
            self.addChild(popOverVC)
            let frame = CGRect(x: UIScreen.main.bounds.width/4, y: UIScreen.main.bounds.height/4, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2 )
            popOverVC.view.frame = frame
            self.view.addSubview(popOverVC.view)
            popOverVC.didMove(toParent: self)
            PopManager().pop()
        }
    }

}




class ViewController2: UIViewController{


    override func viewDidLoad() {
        super.viewDidLoad()
        loadSwiftUIView()
        showAnimate()
        NotificationCenter.default.addObserver(self, selector: #selector(popDidPause), name: .popStarted, object: nil)
    }

    

    @objc private func popDidPause(_ notification: Notification) {
        removeAnimate(failure: true)
    }

    

    func loadSwiftUIView(){
        let popOverVC = UIHostingController(rootView: ContentView(rGuess: 0.5, gGuess: 0.5, bGuess: 0.5))
        self.addChild(popOverVC)
        popOverVC.view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.height/2 )
        self.view.addSubview(popOverVC.view)
        popOverVC.didMove(toParent: self)
        PopManager().pop()
    }

    func showAnimate(){
        self.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
        self.view.alpha = 0.0;
        UIView.animate(withDuration: 0.25, animations: {
            self.view.alpha = 1.0
            self.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
        });
    }

    //removes the view
    func removeAnimate(failure: Bool){

        if failure {
            self.view.endEditing(true)
            UIView.animate(withDuration: 0.25, animations: {
              self.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
              self.view.alpha = 0.0;
            }, completion:{(finished : Bool)  in
                if (finished){
                    self.view.removeFromSuperview()
                    self.removeFromParent()
                }
            });
        }
    }
    

}

Build and run the app and see what you created. Try and play the color matching game, if you hit "check" you can even see a baby popup! The problems from before, like creating multiple popups if you tap outside the popup and the simplicity of the SwiftUI view are remedied.

Feel free to message or email me at [email protected] if you have any questions.

要查看或添加评论,请登录

Ean Krenzin-Blank的更多文章