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:
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
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.
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:
We can do the same thing with a UIScrollView
(red-dashed outline is the scroll view frame):
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:
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):
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:
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:
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:
As the "page" height grows...
we'll be able to scroll the entire "outer view" vertically...
and continue to scroll the "pages" horizontally.
The end result - (scaled way down so it will post 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:
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.