swiftnslayoutconstraintstretchsnapkit

Stretchy Header with UIPageViewController


My problem seems obvious and duplicated but I can't manage to make it work.

I'm trying to achieve the famous stretchy header effect (image's top side stuck to top of UIScrollView when scrolling), but with an UIPageViewController instead of simply an image.

My structure is:

UINavigationBar
   |-- UIScrollView
         |-- UIView (totally optional container)
              |-- UIPageViewController (as UIView, embedded with addChild()) <-- TO STICK
              |-- UIHostingViewController (SwiftUI view with labels, also embedded)
              |-- UITableView (not embedded but could be)

My UIPageViewController contains images to make a carousel, nothing more.
All my views are laid out with NSLayoutConstraints (with visual format for vertical layout in the container).

I trie sticking topAnchor of the page controller's view to the one of self.view (with or without priority) but no luck, and no matter what I do it changes absolutely nothing.

I finally tried to use SnapKit but it doesn't work neither (I don't know much about it but it seems to only be a wrapper for NSLayoutConstaints so I'm not surprised it doesn't work too).

I followed this tutorial, this one and that one but none of them worked.

(How) can I achieve what I want?

EDIT 1: To clarify, my carousel currently has a forced height of 350. I want to achieve this exact effect (that is shown with a single UIImageView) on my whole carousel:

gif

To clarify as much as possible, I want to replicate this effect to my whole UIPageViewController/carousel so that the displayed page/image can have this effect when scrolled.

NOTE: as mentioned in the structure above, I have a (transparent) navigation bar, and my safe area insets are respected (nothing goes under the status bar). I don't think it would change the solution (as the solution is probably a way to stick the top of the carousel to self.view, no matter the frame of self.view) but I prefer you to know everything.

EDIT 2:
Main VC with @DonMag's answer:

    private let info: UITableView = {
        let v = UITableView(frame: .zero, style: .insetGrouped)
        v.backgroundColor = .systemBackground
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    private lazy var infoHeightConstraint: NSLayoutConstraint = {
        // Needed constraint because else standalone UITableView gets an height of 0 even with usual constraints
        // I update this constraint in viewWillAppear & viewDidAppear when the table gets a proper contentSize
        info.heightAnchor.constraint(equalToConstant: 0.0)
    }()
    
    private let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.contentInsetAdjustmentBehavior = .never
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

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

        // MARK: Views declaration
        // Container for carousel
        let stretchyView = UIView()
        stretchyView.translatesAutoresizingMaskIntoConstraints = false
        
        // Carousel
        let carouselController = ProfileDetailCarousel(images: [
            UIImage(named: "1")!,
            UIImage(named: "2")!,
            UIImage(named: "3")!,
            UIImage(named: "4")!
        ])
        addChild(carouselController)
        let carousel: UIView = carouselController.view
        carousel.translatesAutoresizingMaskIntoConstraints = false
        stretchyView.addSubview(carousel)
        carouselController.didMove(toParent: self)
        
        // Container for below-carousel views
        let contentView = UIView()
        contentView.translatesAutoresizingMaskIntoConstraints = false
        
        // Texts and bio
        let bioController = UIHostingController(rootView: ProfileDetailBio())
        addChild(bioController)
        let bio: UIView = bioController.view
        bio.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(bio)
        bioController.didMove(toParent: self)
        
        // Info table
        info.delegate = tableDelegate
        info.dataSource = tableDataSource
        tableDelegate.viewController = self
        contentView.addSubview(info)
        
        [stretchyView, contentView].forEach { v in
            scrollView.addSubview(v)
        }
        view.addSubview(scrollView)
        
        // MARK: Constraints
        let stretchyTop = stretchyView.topAnchor.constraint(equalTo: scrollView.frameLayoutGuide.topAnchor)
        stretchyTop.priority = .defaultHigh
        NSLayoutConstraint.activate([
            // Scroll view
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            
            // Stretchy view
            stretchyTop,
            
            stretchyView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor),
            stretchyView.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
            stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 350.0),
            
            // Carousel
            carousel.topAnchor.constraint(equalTo: stretchyView.topAnchor),
            carousel.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
            carousel.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
            carousel.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
            
            // Content view
            contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
            contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
            contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 350.0),
            contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor),
            
            // Bio
            bio.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10.0),
            bio.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            
            // Info table
            info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
            info.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            info.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            infoHeightConstraint
        ])
    }

Solution

  • Your view hierarchy should be:

    UINavigationBar
       |-- UIScrollView
             |-- UIView ("stretchy" container view)
                  |-- UIPageViewController (as UIView, embedded with asChild())
             |-- UIHostingViewController (SwiftUI view with labels, also embedded)
    

    To get the stretchy view to "stick to the top":

    We constrain the stretchy view's Top to the scroll view's .frameLayoutGuide Top, but we give that constraint a less-than-required .priority so we can "push it" up and off the screen.

    We also give the stretchy view a Height constraint of greater-than-or-equal-to 350. This will allow it to stretch - but not compress - vertically.

    We'll call the view from the UIHostingViewController our "contentView" ... and we'll constrain its Top to the stretchy view's Bottom.

    Then, we give the content view another Top constraint -- this time to the scroll view's .contentLayoutGuide, with a constant of 350 (the height of the stretchy view). This, plus the Leading/Trailing/Bottom constraints defines the "scrollable area."

    When we scroll (pull) down, the content view will "pull down" the Bottom of the stretchy view.

    When we scroll (push) up, the content view will "push up" the entire stretchy view.

    Here's how it looks (too big to add as a gif here): https://i.sstatic.net/4wvxK.jpg

    And here's the sample code to make that. Everything is done via code, so no @IBOutlet or other connections needed. Also note that I used three images for the page views - "ex1", "ex2", "ex3":

    View Controller

    class StretchyHeaderViewController: UIViewController {
        
        let scrollView: UIScrollView = {
            let v = UIScrollView()
            v.contentInsetAdjustmentBehavior = .never
            return v
        }()
        let stretchyView: UIView = {
            let v = UIView()
            return v
        }()
        let contentView: UIView = {
            let v = UIView()
            v.backgroundColor = .systemYellow
            return v
        }()
        
        let stretchyViewHeight: CGFloat = 350.0
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // set to a greter-than-zero value if you want spacing between the "pages"
            let opts = [UIPageViewController.OptionsKey.interPageSpacing: 0.0]
            // instantiate the Page View controller
            let pgVC = SamplePageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: opts)
            // add it as a child controller
            self.addChild(pgVC)
            // safe unwrap
            guard let pgv = pgVC.view else { return }
            pgv.translatesAutoresizingMaskIntoConstraints = false
            // add the page controller view to stretchyView
            stretchyView.addSubview(pgv)
            pgVC.didMove(toParent: self)
            
            NSLayoutConstraint.activate([
                // constrain page view controller's view on all 4 sides
                pgv.topAnchor.constraint(equalTo: stretchyView.topAnchor),
                pgv.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
                pgv.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
                pgv.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
            ])
            
            [scrollView, stretchyView, contentView].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
            }
            
            // add contentView and stretchyView to the scroll view
            [stretchyView, contentView].forEach { v in
                scrollView.addSubview(v)
            }
            
            // add scroll view to self.view
            view.addSubview(scrollView)
            
            let safeG = view.safeAreaLayoutGuide
            let contentG = scrollView.contentLayoutGuide
            let frameG = scrollView.frameLayoutGuide
            
            // keep stretchyView's Top "pinned" to the Top of the scroll view FRAME
            //  so its Height will "stretch" when scroll view is pulled down
            let stretchyTop = stretchyView.topAnchor.constraint(equalTo: frameG.topAnchor, constant: 0.0)
            // priority needs to be less-than-required so we can "push it up" out of view
            stretchyTop.priority = .defaultHigh
            
            NSLayoutConstraint.activate([
                
                // scroll view Top to view Top
                scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
    
                // scroll view Leading/Trailing/Bottom to safe area
                scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
                scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
                scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
                
                // constrain stretchy view Top to scroll view's FRAME
                stretchyTop,
                
                // stretchyView to Leading/Trailing of scroll view FRAME
                stretchyView.leadingAnchor.constraint(equalTo: frameG.leadingAnchor, constant: 0.0),
                stretchyView.trailingAnchor.constraint(equalTo: frameG.trailingAnchor, constant: 0.0),
                
                // stretchyView Height - greater-than-or-equal-to
                //  so it can "stretch" vertically
                stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: stretchyViewHeight),
                
                // content view Leading/Trailing/Bottom to scroll view's CONTENT GUIDE
                contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
                contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
                contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
    
                // content view Width to scroll view's FRAME
                contentView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
    
                // content view Top to scroll view's CONTENT GUIDE
                //  plus Height of stretchyView
                contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: stretchyViewHeight),
    
                // content view Top to stretchyView Bottom
                contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor, constant: 0.0),
        
            ])
            
            // add some content to the content view so we have something to scroll
            addSomeContent()
                    
        }
        
        func addSomeContent() {
            // vertical stack view with 20 labels
            //  so we have something to scroll
            let stack = UIStackView()
            stack.axis = .vertical
            stack.spacing = 32
            stack.backgroundColor = .gray
            stack.translatesAutoresizingMaskIntoConstraints = false
            for i in 1...20 {
                let v = UILabel()
                v.text = "Label \(i)"
                v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
                v.heightAnchor.constraint(equalToConstant: 48.0).isActive = true
                stack.addArrangedSubview(v)
            }
            contentView.addSubview(stack)
            NSLayoutConstraint.activate([
                stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16.0),
                stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16.0),
                stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16.0),
                stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16.0),
            ])
        }
        
    }
    

    Controller for each Page

    class OnePageVC: UIViewController {
        
        var image: UIImage = UIImage() {
            didSet {
                imgView.image = image
            }
        }
        let imgView: UIImageView = {
            let v = UIImageView()
            v.backgroundColor = .systemBlue
            v.contentMode = .scaleAspectFill
            v.clipsToBounds = true
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            view.addSubview(imgView)
            NSLayoutConstraint.activate([
                // constrain image view to all 4 sides
                imgView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
                imgView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
                imgView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
                imgView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
            ])
        }
    }
    

    Sample Page View Controller

    class SamplePageViewController: UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
        
        var controllers: [UIViewController] = []
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let imgNames: [String] = [
                "ex1", "ex2", "ex3",
            ]
            for i in 0..<imgNames.count {
                let aViewController = OnePageVC()
                if let img = UIImage(named: imgNames[i]) {
                    aViewController.image = img
                }
                self.controllers.append(aViewController)
            }
    
            self.dataSource = self
            self.delegate = self
            
            self.setViewControllers([controllers[0]], direction: .forward, animated: false)
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
            if let index = controllers.firstIndex(of: viewController), index > 0 {
                return controllers[index - 1]
            }
            return nil
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
            if let index = controllers.firstIndex(of: viewController), index < controllers.count - 1 {
                return controllers[index + 1]
            }
            return nil
        }
    
    }
    

    Edit

    Looking at the code you posted in your question's Edit... it's a little tough, since I don't know what your ProfileDetailBio view is, but here are a couple tips to help debug this type of situation during development:

    So, for your code...

    // so we can see the contentView frame
    contentView.backgroundColor = .systemYellow
    
    // leave some space on the right-side of bio view, so we
    //   so we can see the contentView behind it
    bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -100.0),
    

    If you run the app, you will likely see that contentView only extends to the bottom of bio - not to the bottom of info.

    If you then do this:

    contentView.clipsToBounds = true
    

    info will likely not be visible at all.

    Checking your constraints, you have:

    bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            
    // Info table
    info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
    

    where it should be:

    // no bio bottom anchor
    //bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
    
    // this is correct
    // Info table
    info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
    
    // add this
    info.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            
    

    Run the app, and you should now again see info, and contentView extends to the bottom of info.

    Assuming bio and info height combined are tall enough to require scrolling, you can undo the "debug / dev" changes and you should be good to go.