skip to Main Content

I am trying to recreate the sheet transition animation like the one in Apple Maps. For example, when you tap on a place, another sheet pops on top of the current sheet and it sorts of ‘merges’ with the sheet right below it.

Here’s a video of what I am exactly referring to: https://streamable.com/guz5cf

Like how do I go about doing it? Searched all over the place with zero information

Is it a view controller or a UIView and are the any masking/blend modes involved? I am trying to understand what sorts of techniques can be used to achieve it. Custom presentation controllers?

Would appreciate if anyone can give a simple example to start from.

This is the code I tried but it is no where close to what is in Apple Maps

import UIKit


class ViewControllerStack {
    static let shared = ViewControllerStack()
    
    var views: [UIViewController] = []
}

class ViewController: UIViewController {
    override func loadView() {
        view = MKMapView()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        let welcomeVC = WelcomeViewController()
        welcomeVC.isModalInPresentation = true
        if let sheet = welcomeVC.sheetPresentationController {
            sheet.detents = [.custom { context in
                return 200
            }, .medium(), .large()]
            sheet.prefersGrabberVisible = true
            sheet.preferredCornerRadius = 15
            sheet.largestUndimmedDetentIdentifier = .large
        }
        
        present(welcomeVC, animated: true)
        ViewControllerStack.shared.views.append(welcomeVC)
    }
}

class WelcomeViewController: UIViewController {
    lazy var blurEffectView: UIVisualEffectView = {
        let blurEffect = UIBlurEffect(style: .systemChromeMaterial)
        let blurEffectView = UIVisualEffectView(effect: blurEffect)
        blurEffectView.frame = view.bounds
        return blurEffectView
    }()
    
    override func viewDidLoad() {            
        view.addSubview(blurEffectView)
        view.sendSubviewToBack(blurEffectView)            
                
        let button = UIButton()
        button.setTitle("Go to View Controller 2", for: .normal)
        button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)
        
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            button.widthAnchor.constraint(equalToConstant: 200),
            button.heightAnchor.constraint(equalToConstant: 44)
        ])
        
        
    }
    
    @objc func buttonTapped() {
        let vc2 = ViewController2()
        vc2.modalPresentationStyle = .currentContext
        vc2.modalTransitionStyle = .coverVertical

        present(vc2, animated: true)
        
        UIView.animate(withDuration: 0.75) {
            self.view.alpha = 0
            self.view.layer.masksToBounds = true
        }

        ViewControllerStack.shared.views.append(vc2)
    }
}

class ViewController2: UIViewController {
    override func viewDidLoad() {
        let blurEffect = UIBlurEffect(style: .systemChromeMaterial)
        let blurEffectView = UIVisualEffectView(effect: blurEffect)
        blurEffectView.frame = view.bounds
        view.addSubview(blurEffectView)
        view.sendSubviewToBack(blurEffectView)
        
        let button = UIButton()
        button.setTitle("Go back to Welcome VC", for: .normal)
        button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)
        
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            button.widthAnchor.constraint(equalToConstant: 200),
            button.heightAnchor.constraint(equalToConstant: 44)
        ])
    }
    
    @objc func buttonTapped() {
        ViewControllerStack.shared.views.removeLast()
        guard let lastVCInStack = ViewControllerStack.shared.views.last else { return }
        
        lastVCInStack.view.alpha = 1
        lastVCInStack.dismiss(animated: true)
    }
}

2

Answers


  1. The following is code that reproduces the sheet transition animation like in Apple Maps.

    To run this code, create a new iOS app project in Xcode. Replace the contents of ViewController.swift with the code below and then add two new Swift files (SheetVC.swift and ModalVC.swift) as below.

    When run, ViewController shows a plain yellow background with a "Present" button. This simulates having a map with tappable points of interest. An initial sheet is shown with SheetVC. This simulates the initial Maps app sheet when no point of interest is selected. Tapping the "Present" button will show a new sheet (ModalVC) over the original with a random color. Tapping the "Present" button again will replace the first color with a new color. This simulates selecting a new point of interest on the map. Dismissing ModalVC (with the close button) reveals the original sheet (SheetVC).

    Start by adding these custom detents (add these to ViewController.swift or some other file you desire):

    extension UISheetPresentationController.Detent.Identifier {
        static let appLarge = UISheetPresentationController.Detent.Identifier("appLarge")
        static let appMedium = UISheetPresentationController.Detent.Identifier("appMedium")
        static let appSmall = UISheetPresentationController.Detent.Identifier("appSmall")
    }
    
    extension UISheetPresentationController.Detent {
        class func appLarge() -> UISheetPresentationController.Detent {
            return self.custom(identifier: .appLarge) { context in
                return context.maximumDetentValue - 1
            }
        }
        class func appMedium() -> UISheetPresentationController.Detent {
            return self.custom(identifier: .appMedium) { context in
                return context.maximumDetentValue * 0.38
            }
        }
        class func appSmall() -> UISheetPresentationController.Detent {
            return self.custom(identifier: .appSmall) { context in
                return 70
            }
        }
    }
    

    These custom detents better simulate the three detents of the Maps app.

    ViewController.swift (this would likely be the VC with the map view):

    class ViewController: UIViewController {
        let initialSheet = SheetVC()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .systemYellow
    
            // Simulate picking a point of interest by showing a random color
            let button = UIButton(primaryAction: UIAction(title: "Present", handler: { action in
                self.initialSheet.show(color: self.randomColor())
            }))
            button.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(button)
            NSLayoutConstraint.activate([
                button.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
                button.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 70),
            ])
        }
    
        private func randomColor() -> UIColor {
            let colors: [UIColor] = [ .blue, .black, .brown, .cyan, .darkGray, .gray, .green, .lightGray, .magenta, .orange, .purple, .red, .yellow ]
    
            return colors.randomElement()!
        }
    
        // Setup and show the initial sheet
        override func viewIsAppearing(_ animated: Bool) {
            super.viewIsAppearing(animated)
    
            initialSheet.isModalInPresentation = true // don't let it be dismissed by swipe
            if let sheet = initialSheet.sheetPresentationController {
                sheet.detents = [.appSmall(), .appMedium(), .appLarge()]
                sheet.largestUndimmedDetentIdentifier = .appMedium
                sheet.selectedDetentIdentifier = .appMedium
                sheet.prefersGrabberVisible = true
                sheet.prefersScrollingExpandsWhenScrolledToEdge = false
                sheet.prefersEdgeAttachedInCompactHeight = true
                sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
            }
            present(initialSheet, animated: true, completion: nil)
        }
    }
    

    SheetVC.swift (the VC controlling the initial sheet’s contents):

    class SheetVC: UIViewController {
        weak var modal: ModalVC?
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .systemBackground
            
            let label = UILabel()
            label.text = "Main Sheet"
            label.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(label)
            NSLayoutConstraint.activate([
                label.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
            ])
        }
    
        // Simulate showing the selected point of interest by showing the new color
        func show(color: UIColor) {
            if let modal {
                // Simply update the color
                modal.show(color: color)
            } else {
                // Need to show secondary sheet with color
                let vc = ModalVC(color: color)
                vc.isModalInPresentation = true // don't allow dismiss by swipe
                // Use same sheet settings as the base sheet
                if let sheet = vc.sheetPresentationController {
                    sheet.detents = [.appSmall(), .appMedium(), .appLarge()]
                    sheet.largestUndimmedDetentIdentifier = .appMedium
                    sheet.selectedDetentIdentifier = .appMedium
                    sheet.prefersGrabberVisible = true
                    sheet.prefersScrollingExpandsWhenScrolledToEdge = false
                    sheet.prefersEdgeAttachedInCompactHeight = true
                    sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
                }
                present(vc, animated: true)
                modal = vc
            }
        }
    }
    

    ModalVC.swift (the VC controlling the details shown in the 2nd sheet):

    class ModalVC: UIViewController {
        var color: UIColor
        var dataView: UIView?
    
        init(color: UIColor) {
            self.color = color
            super.init(nibName: nil, bundle: nil)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .systemBackground
    
            // The close button to dismiss the secondary sheet
            let button = UIButton(type: .close, primaryAction: UIAction(title: "Present", handler: { [weak self] action in
                self?.dismiss(animated: true)
            }))
            button.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(button)
            NSLayoutConstraint.activate([
                button.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
                button.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
            ])
    
            show(color: self.color)
        }
    
        // Simulate showing the details of the chosen point of interest
        func show(color: UIColor) {
            if let old = dataView {
                // Transition from the old color to the new color
                let new = createView(color: color)
                new.alpha = 0
                UIView.animate(withDuration: 0.05, delay: 0, options: [ .curveEaseOut ]) {
                    old.alpha = 0
                } completion: { finished in
                    UIView.animate(withDuration: 0.05, delay: 0.1, options: [ .curveEaseIn ]) {
                        new.alpha = 1
                    } completion: { [weak self] finished in
                        old.removeFromSuperview()
                        self?.dataView = new
                    }
                }
            } else {
                // Initial display
                dataView = createView(color: color)
            }
        }
    
        // This would create and setup a view specific to the chosen point of interest
        private func createView(color: UIColor) -> UIView {
            let v = UIView()
            v.backgroundColor = color
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            NSLayoutConstraint.activate([
                v.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
                v.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -40),
                v.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
                v.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
            ])
    
            return v
        }
    }
    
    Login or Signup to reply.
  2. There is a bit to unpack here…

    You are trying to emulate the "double sheet" behavior of the Apple Maps app. It’s pretty straightforward to present a sheet controller, and then present another one on top of it – which is what it appears Maps does.

    However, based on your comments (and your multiple other questions), you want the sheet(s) to have translucency.

    Couple issues with that.

    When views with less-than 100% opacity overlap, the alphas get added together. In this image, I have two views with white background, each set to .alpha = 0.75:

    enter image description here

    As we see, the overlap area is much more opaque. This is not noticeable in the Maps app, because the sheet views have an alpha of probably 0.99 or 0.995 — so the single sheet is just barely translucent, and when the second sheet is presented on top there is virtually no translucency. One has to look really, really closely to even see it.

    The second issue is that a presented sheet controller has a shadow behind it.

    Here is how it looks when presenting a controller with a completely clear background:

    enter image description here

    and, when presenting a second sheet on top of it, we get a darker shadow (two shadow layers):

    enter image description here

    If we drag the second sheet up to a taller detent, it’s very obvious what’s going on:

    enter image description here

    Again, in the Maps app, not noticeable because the sheets have almost-opaque backgrounds… however, if we do a screen-cap and zoom way in, we can see the extra shadow: https://phpout.com/wp-content/uploads/2024/01/IzzZS.png

    So, how to get this appearance / behavior:

    enter image description here

    One approach is to present a single controller which will hold two "container" views. We load the "content" controllers as children, and add their views to the "container" views, positioning the "detail" view below the "welcome" view.

    To show the "detail" view, we can slide it up, collapsing the height of the "welcome" view. Reverse the process to "dismiss" it.

    Here is some quick example code – no @IBOutlet or @IBAction connections… just create a new project and use a default view controller.


    View Controller class

    class ViewController: UIViewController, MKMapViewDelegate {
    
        override func loadView() {
            view = MKMapView()
        }
        override func viewDidLoad() {
            super.viewDidLoad()
            
            if let v = view as? MKMapView {
                // allow default POI selection
                v.selectableMapFeatures = [.pointsOfInterest]
                v.delegate = self
    
                // let's start the map near Apple HQ
                let c = CLLocation(latitude: 37.32874666861649, longitude: -122.01263053521025)
                let regionRadius: CLLocationDistance = 500
                let coordinateRegion = MKCoordinateRegion(center: c.coordinate,
                                                          latitudinalMeters: regionRadius * 2.0,
                                                          longitudinalMeters: regionRadius * 2.0)
                v.setRegion(coordinateRegion, animated: false)
            }
            
        }
        
        override func viewDidAppear(_ animated: Bool) {
    
            let containerVC = SheetContainerVC()
            containerVC.isModalInPresentation = true
    
            if let sheet = containerVC.sheetPresentationController {
    
                sheet.detents = [.custom { context in
                    return 200
                }, .medium(), .large()]
                sheet.prefersGrabberVisible = true
                sheet.preferredCornerRadius = 15
                sheet.largestUndimmedDetentIdentifier = .large
    
            }
            
            // closure so we can de-select the current POI
            containerVC.detailCloseTapped = { [weak self] in
                guard let self = self else { return }
                if let v = view as? MKMapView,
                   let f = v.selectedAnnotations.first
                {
                    v.deselectAnnotation(f, animated: true)
                }
            }
    
            present(containerVC, animated: true)
    
        }
        
        // user selected a POI
        func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
            if let feat = annotation as? MKMapFeatureAnnotation {
                if let vc = presentedViewController as? SheetContainerVC {
                    vc.showDetail(feat)
                }
            }
        }
        
        // user tapped the map NOT on a POI, or
        //  user tapped a Second POI, which de-selects the First POI
        func mapView(_ mapView: MKMapView, didDeselect annotation: MKAnnotation) {
            // curiously, didDeselect is called twice
            //  the second time, annotation is nil, even though Xcode
            //  says it is not optional
            guard annotation != nil else { return }
            // when a MKAnnotation (POI) is already selected, and the user
            //  selects another POI, didDeselect is called
            //  and then didSelect is called
            // we want to delay "dismissing" the detail view
            //  if we are "replacing" the selected POI
            DispatchQueue.main.async { [weak self] in
                self?.delayedDeselect(annotation: annotation)
            }
        }
        func delayedDeselect(annotation: MKAnnotation) {
            if let v = view as? MKMapView, v.selectedAnnotations.count == 0 {
                // user tapped the map NOT on a POI, so
                //  "dismiss" the details view
                if let vc = presentedViewController as? SheetContainerVC {
                    vc.hideDetail()
                }
            }
        }
    
        // use default "ballon" annotation view
        func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
            return MKMarkerAnnotationView()
        }
        
    }
    

    Container Controller classthis will be sheet-presented

    // this VC will be presented in sheet mode
    //  it will manage the views from Welcome and Detail controllers
    class SheetContainerVC: UIViewController {
        
        // closure to tell the map view we closed the detail VC
        var detailCloseTapped: (()->())?
        
        // "container" views for the views from the child controllers
        let welcomeView = UIView()
        let detailView = UIView()
        
        // the two controllers we will add as children
        let wvc = WelcomeVC()
        let dvc = DetailVC()
        
        // top anchor for detail view
        var detailTop: NSLayoutConstraint!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .clear
            
            let blurEffect = UIBlurEffect(style: .systemThinMaterial)
            let blurEffectView = UIVisualEffectView(effect: blurEffect)
            blurEffectView.frame = view.bounds
            view.addSubview(blurEffectView)
            
            // if we want the blurEffectView to be more Translucent
            blurEffectView.alpha = 0.75
            
            [welcomeView, detailView].forEach { v in
                v.clipsToBounds = true
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
            
            let g = view.safeAreaLayoutGuide
            
            // welcome view height
            //  it will be "collapsed" when detail view is shown
            var welcomeHeight: NSLayoutConstraint = welcomeView.heightAnchor.constraint(equalTo: view.heightAnchor)
            welcomeHeight.priority = .required - 1
            
            // top anchor for detail view
            //  its .isActive will be toggled to animate it into view
            detailTop = detailView.topAnchor.constraint(equalTo: g.topAnchor)
            
            NSLayoutConstraint.activate([
                welcomeView.topAnchor.constraint(equalTo: g.topAnchor),
                welcomeView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                welcomeView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                welcomeHeight,
                
                detailView.topAnchor.constraint(equalTo: welcomeView.bottomAnchor),
                detailView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                detailView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                detailView.heightAnchor.constraint(equalTo: view.heightAnchor),
            ])
            
            // add child controllers and add their views as subviews 
            //  of the "container" views
            
            addChild(wvc)
            guard let wv = wvc.view else { fatalError("Tried to load a controller without a view!") }
            wv.translatesAutoresizingMaskIntoConstraints = false
            welcomeView.addSubview(wv)
            NSLayoutConstraint.activate([
                wv.topAnchor.constraint(equalTo: welcomeView.topAnchor, constant: 0.0),
                wv.leadingAnchor.constraint(equalTo: welcomeView.leadingAnchor, constant: 0.0),
                wv.trailingAnchor.constraint(equalTo: welcomeView.trailingAnchor, constant: 0.0),
                wv.heightAnchor.constraint(equalTo: welcomeView.heightAnchor, constant: 0.0),
            ])
            wvc.didMove(toParent: self)
        
            addChild(dvc)
            guard let dv = dvc.view else { fatalError("Tried to load a controller without a view!") }
            dv.translatesAutoresizingMaskIntoConstraints = false
            detailView.addSubview(dv)
            NSLayoutConstraint.activate([
                dv.topAnchor.constraint(equalTo: detailView.topAnchor, constant: 0.0),
                dv.leadingAnchor.constraint(equalTo: detailView.leadingAnchor, constant: 0.0),
                dv.trailingAnchor.constraint(equalTo: detailView.trailingAnchor, constant: 0.0),
                dv.heightAnchor.constraint(equalTo: detailView.heightAnchor, constant: 0.0),
            ])
            dvc.didMove(toParent: self)
    
            // set closure for detail controller
            dvc.closeTapped = { [weak self] in
                guard let self = self else { return }
                self.hideDetail()
            }
    
        }
    
        func showDetail(_ feat: MKMapFeatureAnnotation) {
            // if detail view is already showing
            //  let's fade it out, fill it, fade it in
            // else
            //  fill it in and animate it up and into-view
            if detailTop.isActive {
                UIView.animate(withDuration: 0.2, animations: {
                    self.dvc.view.alpha = 0.0
                }, completion: { _ in
                    self.dvc.fillDetails(feat)
                    UIView.animate(withDuration: 0.2, animations: {
                        self.dvc.view.alpha = 1.0
                    })
                })
            } else {
                dvc.fillDetails(feat)
                detailTop.isActive = true
                UIView.animate(withDuration: 0.3, animations: {
                    self.view.layoutIfNeeded()
                })
            }
        }
        func hideDetail() {
            // call the closure to de-select the POI
            detailCloseTapped?()
            // animate detail view down and out-of-view
            detailTop.isActive = false
            UIView.animate(withDuration: 0.2, animations: {
                self.view.layoutIfNeeded()
            })
        }
    }
    

    Welcome Controller class

    class WelcomeVC: UIViewController {
    
        let titleLabel = UILabel()
        let subtitleLabel = UILabel()
        let btn = UIButton()
        let anotherLabel = UILabel()
        let imgView = UIImageView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            titleLabel.textColor = .label
            titleLabel.font = .systemFont(ofSize: 24.0, weight: .bold)
            titleLabel.text = "Welcome"
            
            subtitleLabel.textColor = .label
            subtitleLabel.font = .systemFont(ofSize: 20.0, weight: .regular)
            subtitleLabel.text = "This is the Welcome view."
            subtitleLabel.numberOfLines = 0
            
            btn.backgroundColor = .systemBackground
            btn.layer.cornerRadius = 8
            btn.setTitle("Do Something", for: [])
            btn.setTitleColor(.systemBlue, for: .normal)
            btn.setTitleColor(.lightGray, for: .highlighted)
            
            anotherLabel.textColor = .label
            anotherLabel.font = .italicSystemFont(ofSize: 20.0)
            anotherLabel.text = "This is another label.nI expect you will have additional UI elements here on the "Welcome" screen. For now, we just have this label and an Image View."
            anotherLabel.numberOfLines = 0
            
            if let img = UIImage(systemName: "swift") {
                imgView.image = img
            }
            imgView.tintColor = .systemOrange
            imgView.contentMode = .scaleAspectFit
            imgView.backgroundColor = .white.withAlphaComponent(0.5)
            imgView.layer.cornerRadius = 16.0
            imgView.layer.borderWidth = 1
            imgView.layer.borderColor = UIColor.red.cgColor
    
            [titleLabel, subtitleLabel, btn, anotherLabel, imgView].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
            
            self.additionalSafeAreaInsets = .init(top: 12.0, left: 12.0, bottom: 12.0, right: 12.0)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                titleLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                titleLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                titleLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                
                subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8.0),
                subtitleLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 16.0),
                subtitleLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
                
                btn.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 12.0),
                btn.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 16.0),
                btn.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
    
                anotherLabel.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 12.0),
                anotherLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 16.0),
                anotherLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
                
                imgView.topAnchor.constraint(equalTo: anotherLabel.bottomAnchor, constant: 12.0),
                imgView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 32.0),
                imgView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -32.0),
                imgView.heightAnchor.constraint(equalTo: imgView.widthAnchor),
    
            ])
            
            btn.addTarget(self, action: #selector(gotTap(_:)), for: .touchUpInside)
        }
        
        @objc func gotTap(_ sender: Any?) {
            print("Do Something...")
            // for this example, let's change the button background color so we know something happened
            btn.backgroundColor = btn.backgroundColor == .systemBackground ? .systemYellow : .systemBackground
        }
    
    }
    

    Detail Controller class

    class DetailVC: UIViewController {
        
        // closure to inform that the close button was tapped
        var closeTapped: (()->())?
        
        let titleLabel = UILabel()
        let subtitleLabel = UILabel()
        let closeBtn = UIButton()
        let latLonLabel = UILabel()
        let anotherLabel = UILabel()
        
        var imgViews: [UIImageView] = []
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            titleLabel.textColor = .label
            titleLabel.font = .systemFont(ofSize: 24.0, weight: .bold)
            titleLabel.text = "Detail"
            titleLabel.numberOfLines = 0
    
            subtitleLabel.textColor = .label
            subtitleLabel.font = .systemFont(ofSize: 20.0, weight: .regular)
            subtitleLabel.text = "This is the Detail view."
            subtitleLabel.numberOfLines = 0
            
            var cfg = UIImage.SymbolConfiguration(pointSize: 20.0, weight: .bold, scale: .large)
            if let x = UIImage(systemName: "xmark.circle.fill", withConfiguration: cfg) {
                closeBtn.setImage(x, for: [])
            }
    
            latLonLabel.textColor = .blue
            latLonLabel.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular)
            latLonLabel.text = "Lat:nLon:"
            latLonLabel.numberOfLines = 0
    
            anotherLabel.textColor = .label
            anotherLabel.font = .italicSystemFont(ofSize: 18.0)
            anotherLabel.text = "I expect you will have additional UI elements here on the "Detail" screen. For now, we'll use a grid of image views with loosely related SF Symbols."
            anotherLabel.numberOfLines = 0
    
            // let's create a grid of image views so we have some content in the view
    
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.spacing = 20
            
            for _ in stride(from: 0, to: 12, by: 3) {
                let hs = UIStackView()
                hs.spacing = 20
                hs.distribution = .fillEqually
                for _ in 0..<3 {
                    let iv = UIImageView()
                    iv.contentMode = .center
                    iv.tintColor = .red
                    let v = UIView()
                    iv.translatesAutoresizingMaskIntoConstraints = false
                    v.addSubview(iv)
                    v.backgroundColor = .white.withAlphaComponent(0.75)
                    v.layer.cornerRadius = 16.0
                    v.layer.borderWidth = 1
                    v.layer.borderColor = UIColor.darkGray.cgColor
                    iv.centerXAnchor.constraint(equalTo: v.centerXAnchor).isActive = true
                    iv.centerYAnchor.constraint(equalTo: v.centerYAnchor).isActive = true
                    v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
                    v.addSubview(iv)
                    hs.addArrangedSubview(v)
                    imgViews.append(iv)
                }
                stackView.addArrangedSubview(hs)
            }
            
            [titleLabel, subtitleLabel, closeBtn, latLonLabel, anotherLabel, stackView].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
            
            self.additionalSafeAreaInsets = .init(top: 12.0, left: 12.0, bottom: 12.0, right: 12.0)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                titleLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                titleLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                titleLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                
                subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8.0),
                subtitleLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 16.0),
                subtitleLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
                
                closeBtn.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                closeBtn.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                closeBtn.heightAnchor.constraint(equalTo: closeBtn.widthAnchor),
                
                latLonLabel.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 12.0),
                latLonLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 24.0),
                latLonLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
                
                anotherLabel.topAnchor.constraint(equalTo: latLonLabel.bottomAnchor, constant: 12.0),
                anotherLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 16.0),
                anotherLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
                
                stackView.topAnchor.constraint(equalTo: anotherLabel.bottomAnchor, constant: 20.0),
                stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 32.0),
                stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -32.0),
    
            ])
    
            closeBtn.tintColor = .black
            closeBtn.addTarget(self, action: #selector(closeBtnTap(_:)), for: .touchUpInside)
        }
        
        @objc func closeBtnTap(_ sender: Any?) {
            // call the closure
            closeTapped?()
        }
        
        func fillDetails(_ feat: MKMapFeatureAnnotation) {
            // for this example, we'll fill in a few labels
            //  with data from the selected POI
            titleLabel.text = feat.title ?? "No Title"
            latLonLabel.text = "Lat: (feat.coordinate.latitude)nLon: (feat.coordinate.longitude)"
    
            var idx: Int = 0
            
            if let cat = feat.pointOfInterestCategory {
                subtitleLabel.text = cat.rawValue
    
                switch cat {
                case .parking:
                    idx = 1
                    ()
                case .restaurant:
                    idx = 2
                    ()
                case .cafe:
                    idx = 3
                    ()
                case .store:
                    idx = 4
                    ()
                case .hotel:
                    idx = 5
                    ()
                case .fitnessCenter:
                    idx = 6
                    ()
                default:
                    idx = 0
                    ()
                }
    
            } else {
                subtitleLabel.text = "No Category"
                idx = 0
            }
    
            let colors: [UIColor] = [
                .systemRed, .systemBlue, .systemGreen, .systemYellow, .systemMint, .systemIndigo, .systemBrown,
            ]
            let symsSet: [String] = ImgData().sysNames[idx]
    
            let cfg = UIImage.SymbolConfiguration(pointSize: 32.0, weight: .regular, scale: .medium)
            for (i, iv) in imgViews.enumerated() {
                if i < symsSet.count, let img = UIImage(systemName: symsSet[i], withConfiguration: cfg) {
                    iv.image = img
                } else {
                    iv.image = nil
                }
                iv.tintColor = colors[idx % colors.count]
            }
        }
    }
    

    Data classso we can show "related" images

    class ImgData: NSObject {
    
        let sysNames: [[String]] = [
            [
                "person",
                "person.fill",
                "person.circle",
                "person.circle.fill",
                "person.fill.turn.right",
                "person.fill.turn.down",
                "person.fill.turn.left",
                "person.fill.checkmark",
                "person.fill.xmark",
                "person.fill.questionmark",
                "figure.stand",
                "figure.wave",
            ],
            [
                "car",
                "car.fill",
                "car.circle",
                "car.circle.fill",
                "bolt.car",
                "bolt.car.fill",
                "car.2",
                "car.2.fill",
                "car.side",
                "car.side.fill",
                "suv.side",
                "suv.side.fill",
            ],
            [
                "house.lodge",
                "house.lodge.fill",
                "fork.knife",
                "fork.knife.circle",
                "fork.knife.circle.fill",
                "takeoutbag.and.cup.and.straw",
                "takeoutbag.and.cup.and.straw.fill",
                "wineglass",
                "wineglass.fill",
                "cup.and.saucer",
                "cup.and.saucer.fill",
            ],
            [
                "house.lodge",
                "house.lodge.fill",
                "cup.and.saucer",
                "cup.and.saucer.fill",
                "mug",
                "mug.fill",
                "birthday.cake",
                "birthday.cake.fill",
            ],
            [
                "bag",
                "bag.fill",
                "cart",
                "cart.fill",
                "creditcard",
                "creditcard.and.123",
                "giftcard",
                "giftcard.fill",
                "banknote",
                "dollarsign",
                "dollarsign.circle",
                "dollarsign.circle.fill",
            ],
            [
                "house.lodge",
                "bed.double",
                "case",
                "case.fill",
                "latch.2.case",
                "latch.2.case.fill",
                "suitcase",
                "suitcase.fill",
                "suitcase.cart",
                "suitcase.cart.fill",
                "suitcase.rolling",
                "suitcase.rolling.fill",
            ],
            [
                "figure.walk",
                "figure.run",
                "figure.bowling",
                "figure.climbing",
                "figure.cooldown",
                "figure.core.training",
                "figure.elliptical",
                "figure.flexibility",
                "figure.strengthtraining.functional",
                "figure.jumprope",
                "figure.mixed.cardio",
                "figure.pilates",
            ],
            
        ]
        
    }
    

    Please note: this is Example Code Only!!!

    It should not be considered "production ready" — it’s intended to be a Learning exercise.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search