iosswiftuiscrollviewautolayoutuistackview

UIPageViewController inside a UIStackView or UITableView


I was trying to create an onboarding UI using UIKit and Storyboard. I had a requirement where some parts needed to be static / sticky and some parts had to scroll horizontally as the user swiped on the onboarding.

This should give you a better idea:

Onboarding swift UIPageViewController in UIStackView UIScrollView UITableView Autolayout

To achieve the above, I used the following view hierarchy set up in storyboard

UIViewController

While this works fine in a normal set up, I have the need to support dynamic / large text which the user can update from settings / control panel.

The issue I have now is that the content grows within the scrollview inside the UIPageViewController as shown

UIStackView UIScrollView UIPageController Swift Onboarding

The white box above is not part of the UI but just me trying to show that the vertical scrolling is restricted to that area.

I understand that because of my set up, this is the correct behavior.

My question / objective: What would be a better layout structure so that the whole view grows when larger text fonts are activated such that the view scrolls as a whole rather than the scrolling being restricted to that small portion of the screen which would be mean the page indicator and bottom buttons going below the screen for larger text sizes.

Please bear in mind that only the image, title and description should be horizontally scrollable.

I tried to bring the scrollview and stackview out of the PageViewController to set up something like this:

UIViewController

However, since I'm using autolayout, I get an error in the storyboard that the page view controller's height couldn't be determined.

What would be the right way to set this up in storyboard or programatically if not possible via storyboard ?

I'm open to switching to tableview or collectionview if that makes life easier.


Solution

  • After comment discussion, I think I understand your ultimate goal.

    You're creating an "Onboarding" UI, so I'll make a few assumptions...

    Therefore, we shouldn't have to worry about memory issues if the "pages" are not dynamically allocated.

    A UIPageViewController is a nice component -- but, it does a LOT "behind-the-scenes." In particular, it sets the "page frames" to match its frame. In your case, you want its frame to grow in height to match the pages height.

    That probably could be done, but it will be much easier to NOT use a page view controller.

    Instead, let's use a standard UIScrollView.


    First, if we embed a UILabel in a UIView, and constrain the Height of the view equal to the Height of the label (assuming we have 8-point constraints from the table to the view):

    someView.heightAnchor.constraint(equalTo: someLabel.heightAnchor, constant: 16.0).isActive = true
    

    the Yellow view will grow in height as the label grows:

    enter image description here

    We can do the same thing with a UIScrollView (red-dashed outline is the scroll view frame):

    enter image description here

    The scroll view frame Height grows with the label height, and we can still scroll horizontally but not vertically.


    So, let's start by putting the "pages" into a horizontal stack view:

    enter image description here

    and then embed that stack view in a scroll view.

    If we constrain each page Width to the Width of the scroll view, and set a Height constraint on the scroll view, we'll get this (thick black dashed outline is the scroll view frame):

    enter image description here

    So far, nothing special.

    However, if the page heights change - such as using .adjustsFontForContentSizeCategory - true on the labels - without doing anything else - we get this:

    enter image description here

    enter image description here

    which results in vertical as well as horizontal scrolling -- as expected.

    For the target layout, though, we want the scroll view's Height to grow to match the new page Height.

    Let's constrain the scroll view's Height to the stack view's Height:

        NSLayoutConstraint.activate([
            // "normal" Top/Leading/Trailing constraints
            pageScrollView.topAnchor.constraint(equalTo: view.topAnchor),
            pageScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pageScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            
            // instead of setting a Height related to the view, or a constant value
            //  we'll constrain the Height to the *stack view's* height
            pageScrollView.heightAnchor.constraint(equalTo: pageStackView.heightAnchor),
        ])
    

    Now, as our "pages" grow in height, the scroll view frame will also grow:

    enter image description here

    enter image description here

    Once we embed our pageScrollView in an "outer" scroll view, and constrain it relative to the other "outer" UI elements, we'll reach the target layout.

    Solid black outline is the "iPhone" frame; yellow long-dash outline is the "outer" scroll view frame; white dash outline is the "page" scroll view frame:

    enter image description here

    As the "page" height grows...

    enter image description here

    we'll be able to scroll the entire "outer view" vertically...

    enter image description here

    and continue to scroll the "pages" horizontally.

    enter image description here

    enter image description here

    The end result - (scaled way down so it will post here):

    enter image description here

    And, finally, some sample code. Everything is done via code, so no complicated Storyboard designing or @IBOutlet / @IBAction connections needed:


    Simple struct for the "page" data:

    struct PageData {
        var title: String = ""
        var desc: String = ""
        
        // maybe different images on each page?
        var imgName: String = ""
    }
    

    Sample "page" view controller - which we'll add as children and grab the views. ImageView, Title and Description labels:

    class SamplePageVC: UIViewController {
        
        let titleLabel: UILabel = UILabel()
        let descLabel: UILabel = UILabel()
        let imgView: UIImageView = UIImageView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .clear
            
            guard let customFont1 = UIFont(name: "TimesNewRomanPSMT", size: 32.0),
                  let customFont2 = UIFont(name: "Verdana", size: 16.0)
            else {
                fatalError("Could not load font!")
            }
            
            titleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont1)
            descLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont2)
            
            [titleLabel, descLabel].forEach { v in
                v.adjustsFontForContentSizeCategory = true
                v.textAlignment = .center
                v.textColor = .white
                v.numberOfLines = 0
                v.setContentHuggingPriority(.required, for: .vertical)
                v.setContentCompressionResistancePriority(.required, for: .vertical)
            }
            
            // might want to set a MAX Content Size Category?
            //view.maximumContentSizeCategory = .accessibilityExtraLarge
            
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.alignment = .center
            stackView.spacing = 6
            
            [imgView, titleLabel, descLabel].forEach { v in
                stackView.addArrangedSubview(v)
            }
            
            stackView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stackView)
            
            let g = view.safeAreaLayoutGuide
            let bc: NSLayoutConstraint = stackView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -8.0)
            bc.priority = .required - 1
            
            NSLayoutConstraint.activate([
                
                stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                stackView.bottomAnchor.constraint(lessThanOrEqualTo: g.bottomAnchor, constant: -8.0),
                bc,
                
                imgView.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -60.0),
                imgView.heightAnchor.constraint(equalTo: imgView.widthAnchor, multiplier: 1.0),
                
                titleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -20.0),
                descLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -20.0),
                
            ])
        }
        
    }
    

    Sample "onboarding" view controller - uses everything discussed above:

    class OnboardingVC: UIViewController, UIScrollViewDelegate {
        
        let skipBtn: UIButton = UIButton()
        let subscribeBtn: UIButton = UIButton()
        let loginBtn: UIButton = UIButton()
        
        let pgCtrl: UIPageControl = UIPageControl()
        
        let outerScrollView: UIScrollView = UIScrollView()
        let outerContentView: UIView = UIView()
        
        let pageScrollView: UIScrollView = UIScrollView()
        let pageStackView: UIStackView = UIStackView()
        
        var pageData: [PageData] = [
            PageData(title: "First Page", desc: "Some description about it.", imgName: "pgXC"),
            PageData(title: "Short", desc: "This page has somewhat longer description text.", imgName: ""),
            PageData(title: "A Longer Title", desc: "This page will have even more text in the description label. That will help demonstrate the height matching and resulting layout / scrolling changes.", imgName: ""),
            PageData(title: "Final", desc: "This is the last page.", imgName: ""),
        ]
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .init(red: 1.0, green: 0.25, blue: 0.2, alpha: 1.0)
            
            guard let skipFont = UIFont(name: "Verdana", size: 17.0),
                  let btnFont = UIFont(name: "Verdana-Bold", size: 16.0)
            else {
                fatalError("Could not load font!")
            }
    
            skipBtn.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: skipFont)
            subscribeBtn.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: btnFont)
            loginBtn.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: btnFont)
    
            for (btn, str) in zip([skipBtn, subscribeBtn, loginBtn], ["Skip", "Subscribe", "Login"]) {
                btn.titleLabel?.adjustsFontForContentSizeCategory = true
                btn.setTitle(str, for: [])
                btn.layer.cornerRadius = 6
            }
            
            skipBtn.setTitleColor(.white, for: .normal)
            skipBtn.setTitleColor(.lightGray, for: .highlighted)
            skipBtn.setContentCompressionResistancePriority(.required, for: .vertical)
            
            subscribeBtn.setTitleColor(.blue, for: .normal)
            subscribeBtn.setTitleColor(.systemBlue, for: .highlighted)
            subscribeBtn.backgroundColor = .white
            
            loginBtn.setTitleColor(.white, for: .normal)
            loginBtn.setTitleColor(.lightGray, for: .highlighted)
            loginBtn.layer.borderColor = UIColor.white.cgColor
            loginBtn.layer.borderWidth = 1
            
            let btnStack: UIStackView = UIStackView()
            btnStack.axis = .vertical
            btnStack.spacing = 12
            
            // let's add top and bottom padding for the buttons
            //  we're not using UIButtonConfiguration so we ignore the deprecation warnings
            [subscribeBtn, loginBtn].forEach { v in
                var edges = v.contentEdgeInsets
                edges.top = 12.0
                edges.bottom = 12.0
                v.contentEdgeInsets = edges
            }
            
            // add Page Control and bottom buttons to vertical stack view
            //  set Hugging and Compression priorities to .required so they
            //  won't stretch or collapse vertically
            [pgCtrl, subscribeBtn, loginBtn].forEach { v in
                btnStack.addArrangedSubview(v)
                v.setContentHuggingPriority(.required, for: .vertical)
                v.setContentCompressionResistancePriority(.required, for: .vertical)
            }
            
            // add "pages" stack view to "page" scroll view
            pageStackView.translatesAutoresizingMaskIntoConstraints = false
            pageScrollView.addSubview(pageStackView)
            
            // add elements to outerContentView
            [skipBtn, pageScrollView, btnStack].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                outerContentView.addSubview(v)
            }
            
            // add outerContentView to outerScrollView
            outerContentView.translatesAutoresizingMaskIntoConstraints = false
            outerScrollView.addSubview(outerContentView)
            
            // add outerScrollView to (self) view
            outerScrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(outerScrollView)
            
            let g = view.safeAreaLayoutGuide
            
            var cg = pageScrollView.contentLayoutGuide
            var fg = pageScrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // constrain all 4 sides of pageStackView to pageScrollView.contentLayoutGuide
                pageStackView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
                pageStackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
                pageStackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
                pageStackView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
                
                // constrain skip button Top/Trailing
                skipBtn.topAnchor.constraint(equalTo: outerContentView.topAnchor, constant: 12.0),
                skipBtn.trailingAnchor.constraint(equalTo: outerContentView.trailingAnchor, constant: -40.0),
                
                // constrain pageScrollView
                //  Top 8-points below skip button Bottom
                //  Leading/Trailing to outerContentView (so, full width)
                pageScrollView.topAnchor.constraint(equalTo: skipBtn.bottomAnchor, constant: 8.0),
                pageScrollView.leadingAnchor.constraint(equalTo: outerContentView.leadingAnchor, constant: 0.0),
                pageScrollView.trailingAnchor.constraint(equalTo: outerContentView.trailingAnchor, constant: 0.0),
                
                // constrain pageScrollView HEIGHT to pageStackView HEIGHT
                //  now, the scroll view Height will match the "pages" height
                pageScrollView.heightAnchor.constraint(equalTo: pageStackView.heightAnchor, constant: 0.0),
                
                // constrain btnStack
                //  Top >= pageScrollView Bottom plus a little "padding space"
                //  Leading/Trailing to outerContentView plus a little "padding space" on the sides
                //  Bottom to outerContentView plus a little "padding space"
                btnStack.topAnchor.constraint(greaterThanOrEqualTo: pageScrollView.bottomAnchor, constant: 12.0),
                btnStack.leadingAnchor.constraint(equalTo: outerContentView.leadingAnchor, constant: 20.0),
                btnStack.trailingAnchor.constraint(equalTo: outerContentView.trailingAnchor, constant: -20.0),
                btnStack.bottomAnchor.constraint(equalTo: outerContentView.bottomAnchor, constant: -12.0),
                
            ])
            
            cg = outerScrollView.contentLayoutGuide
            fg = outerScrollView.frameLayoutGuide
            
            // we want outerContentView to be the same Height as outerScrollView
            //  but less-than-required Priority so it can grow based on its subviews
            let hc: NSLayoutConstraint = outerContentView.heightAnchor.constraint(equalTo: fg.heightAnchor, constant: 0.0)
            hc.priority = .required - 1
            
            NSLayoutConstraint.activate([
                
                // constrain all 4 sides of outerContentView to outerScrollView.contentLayoutGuide
                outerContentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
                outerContentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
                outerContentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
                outerContentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
                
                // outerContentView Width to outerScrollView.frameLayoutGuide Width
                //  so we will never get horizontal scrolling
                outerContentView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: 0.0),
                hc,
                
                // constrain all 4 sides of outerScrollView to (self) view
                outerScrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                outerScrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                outerScrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                outerScrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
                
            ])
            
            // add page view VCs for each data item
            //  we could do this with a "SamplePageView" UIView subclass
            //  but this shows how to use UIViewController if that's how the "pages" are setup
            
            for (idx, d) in pageData.enumerated() {
                
                let vc = SamplePageVC()
                
                addChild(vc)
                
                // add its view to pageStackView
                pageStackView.addArrangedSubview(vc.view)
                
                vc.didMove(toParent: self)
                
                vc.titleLabel.text = d.title
                vc.descLabel.text = d.desc
                
                if !d.imgName.isEmpty, let img = UIImage(named: d.imgName) {
                    vc.imgView.image = img
                } else if let img = UIImage(systemName: "\(idx).circle.fill") {
                    vc.imgView.image = img
                    vc.imgView.tintColor = .orange
                }
                
                // each page view Width is equal to pageScrollView.frameLayoutGuide width
                vc.view.widthAnchor.constraint(equalTo: pageScrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
                
            }
            
            // we *probably* do not want to see scroll indicators
            outerScrollView.showsHorizontalScrollIndicator = false
            outerScrollView.showsVerticalScrollIndicator = false
            pageScrollView.showsHorizontalScrollIndicator = false
            pageScrollView.showsVerticalScrollIndicator = false
            
            // enable paging for the "pages"
            pageScrollView.isPagingEnabled = true
            
            // we will implement scrollViewDidScroll() so we can update the page control
            //  when the user drags the pages left/right
            pageScrollView.delegate = self
            
            pgCtrl.numberOfPages = pageData.count
            pgCtrl.addTarget(self, action: #selector(changePage(_:)), for: .valueChanged)
            
        }
        
        @objc func changePage(_ sender: UIPageControl) {
            let x: CGFloat = pageScrollView.frame.width * CGFloat(sender.currentPage)
            UIView.animate(withDuration: 0.3, animations: {
                self.pageScrollView.contentOffset.x = x
            })
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            if scrollView == pageScrollView {
                let pg: Int = Int(floor((scrollView.contentOffset.x + scrollView.frame.width * 0.5) / scrollView.frame.width))
                pgCtrl.currentPage = pg
            }
        }
        
    }
    

    Sample Dev Mode "onboarding" view controller - subclass of OnboardingVC the colorizes and outlines the UI elements. It will produce the screen-caps shown above (when run on an iPad) to make it easier to see what's going on:

    // MARK: subclass of OnboardingVC
    //  for use during development
    //  colorizes and outlines view elements
    //  centers a "simulated" device frame so we can see "outside the frame"
    
    class DevOnboardingVC: OnboardingVC {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let g = view.safeAreaLayoutGuide
            
            // we need to re-build the outerScrollView constraints
            outerScrollView.removeFromSuperview()
            view.addSubview(outerScrollView)
            
            // we'll make the "device frame"
            //  90% of the view height, or
            //  640-points, whichever is smaller
            let targetHeightC: NSLayoutConstraint = outerScrollView.heightAnchor.constraint(equalTo: g.heightAnchor, multiplier: 0.9)
            targetHeightC.priority = .required - 1
            let maxHeightC: NSLayoutConstraint = outerScrollView.heightAnchor.constraint(lessThanOrEqualToConstant: 640.0)
            
            NSLayoutConstraint.activate([
                
                outerScrollView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                outerScrollView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
                outerScrollView.widthAnchor.constraint(equalToConstant: 300.0),
                maxHeightC, targetHeightC,
                
            ])
            
            view.backgroundColor = UIColor(white: 0.90, alpha: 1.0)
            
            outerContentView.backgroundColor = .init(red: 1.0, green: 0.25, blue: 0.2, alpha: 1.0)
            
            outerScrollView.clipsToBounds = false
            pageScrollView.clipsToBounds = false
            
            pageStackView.arrangedSubviews.forEach { v in
                v.backgroundColor = .white.withAlphaComponent(0.5)
                v.backgroundColor = .init(red: 1.0, green: 0.25, blue: 0.2, alpha: 1.0).withAlphaComponent(0.5)
                v.layer.borderColor = UIColor.systemBlue.cgColor
                v.layer.borderWidth = 2
            }
            self.children.forEach { vc in
                if let vc = vc as? SamplePageVC {
                    vc.titleLabel.backgroundColor = .systemGreen
                    vc.descLabel.backgroundColor = .systemGreen
                }
            }
            
            let dashV1 = DashedBorderView()
            dashV1.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(dashV1)
            
            let dashV2 = DashedBorderView()
            dashV2.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(dashV2)
            
            let dashV3 = DashedBorderView()
            dashV3.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(dashV3)
            
            NSLayoutConstraint.activate([
                dashV1.topAnchor.constraint(equalTo: outerScrollView.topAnchor, constant: 0.0),
                dashV1.leadingAnchor.constraint(equalTo: outerScrollView.leadingAnchor, constant: 0.0),
                dashV1.trailingAnchor.constraint(equalTo: outerScrollView.trailingAnchor, constant: 0.0),
                dashV1.bottomAnchor.constraint(equalTo: outerScrollView.bottomAnchor, constant: 0.0),
                
                dashV2.topAnchor.constraint(equalTo: outerScrollView.topAnchor, constant: 0.0),
                dashV2.leadingAnchor.constraint(equalTo: outerScrollView.leadingAnchor, constant: 0.0),
                dashV2.trailingAnchor.constraint(equalTo: outerScrollView.trailingAnchor, constant: 0.0),
                dashV2.bottomAnchor.constraint(equalTo: outerScrollView.bottomAnchor, constant: 0.0),
                
                dashV3.topAnchor.constraint(equalTo: pageScrollView.topAnchor, constant: 0.0),
                dashV3.leadingAnchor.constraint(equalTo: pageScrollView.leadingAnchor, constant: 0.0),
                dashV3.trailingAnchor.constraint(equalTo: pageScrollView.trailingAnchor, constant: 0.0),
                dashV3.bottomAnchor.constraint(equalTo: pageScrollView.bottomAnchor, constant: 0.0),
            ])
            
            dashV1.color = .black
            dashV1.lineWidth = 16
            dashV1.position = .outside
            dashV1.dashPattern = []
            
            dashV2.color = .systemYellow
            dashV2.lineWidth = 3
            dashV2.dashPattern = [60, 8]
            
            dashV3.color = .white
            dashV3.lineWidth = 2
            
        }
        
    }
    

    Dashed Border View - used by the "Dev Mode" class:

    class DashedBorderView: UIImageView {
    
        enum BorderPosition {
            case inside, middle, outside
        }
        
        public var position: BorderPosition = .middle { didSet { setNeedsLayout() } }
        public var dashPattern: [NSNumber] = [16, 16] { didSet { dashedLineLayer.lineDashPattern = dashPattern } }
        public var lineWidth: CGFloat = 1.0 { didSet { dashedLineLayer.lineWidth = lineWidth } }
        public var color: UIColor = .red { didSet { dashedLineLayer.strokeColor = color.cgColor } }
        
        override class var layerClass: AnyClass { CAShapeLayer.self }
        var dashedLineLayer: CAShapeLayer { layer as! CAShapeLayer }
        
        init() {
            super.init(frame: .zero)
            commonInit()
        }
        override init(image: UIImage?) {
            super.init(image: image)
            commonInit()
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            
            // this view will usually be overlaid on top of an interactive view
            //  so disable by default
            isUserInteractionEnabled = false
            
            dashedLineLayer.fillColor = UIColor.clear.cgColor
            dashedLineLayer.strokeColor = color.cgColor
            dashedLineLayer.lineWidth = lineWidth
            dashedLineLayer.lineDashPattern = dashPattern
            
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            switch position {
            case .inside:
                dashedLineLayer.path = CGPath(rect: bounds.insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), transform: nil)
            case .outside:
                dashedLineLayer.path = CGPath(rect: bounds.insetBy(dx: -lineWidth * 0.5, dy: -lineWidth * 0.5), transform: nil)
            case .middle:
                dashedLineLayer.path = CGPath(rect: bounds, transform: nil)
            }
        }
        
    }
    

    Important Notes

    I also posted a project with all the above code here: https://github.com/DonMag/PageViewApproach


    Edit - a little more explanation...

    Big benefits of using a UIPageViewController or UICollectionView (whether using flow, custom or compositional layout) is memory management ... you could have 100s of "pages" and not have to worry.

    With this Layout goal, though, the dynamic height is always going to be the issue... because both of those classes are also designed to "layout their pages / cells" based on the frame we set for the PageViewController or CollectionView.

    Consider 5 "pages" that look like this:

    enter image description here

    In a page view controller or a collection view, we can't set the frame height (yellow rect) because we don't know the "max height needed" until Page 4 is instantiated.

    We could calculate the max height on load, for example:

    func getMaxHeight() -> CGFloat {
        
        var maxHeight: CGFloat = 0.0
        for i in 0..<numPages {
            let v = MyPageView()
            v.fillLabels(forPage: i)
            let sz = v.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
            maxHeight = max(maxHeight, sz.height)
        }
        return maxHeight
        
    }
    

    Or, if we have "cached" the page views in memory:

    func getMaxHeight() -> CGFloat {
        
        var maxHeight: CGFloat = 0.0
        pageViews.forEach { v in
            let sz = v.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
            maxHeight = max(maxHeight, sz.height)
        }
        return maxHeight
        
    }
    

    And because you're using Dynamic Type, we'd also need to call that on UITraitCollection.preferredContentSizeCategory change and update the frame height.

    Personally, I would go with the scrollview approach, and simply let auto-layout handle it for me.