I have a view with 3 dynamic labels inside it and I am trying to find a way to centre it vertically in a scroll view but when its dynamic labels are too large to fit on a page, make the text start from the top. What Xcode is doing at the moment is this:
What I am trying to do is this:
Any ideas about how to achieve this? Thanks.
You can accomplish this by embedding the labels in a stack view and embedding the stack view in a UIView. The label text will expand the stack view vertically, which will expand the content view vertically, which will control the scroll view's .contentSize
.
Black is the scroll view; blue is the content view; stack view only shows as thin gray outline; labels are yellow, green and cyan. The background colors just make it easier to see what's what.
Bunch of steps, but should be clear:
0
for top/leading/trailing/bottom of contentView to scrollViewVertical / Fill / Fill / Spacing: 20
>= 8
Setting the height Priority of the contentView to 250 will allow it to expand vertically based on the text in the labels.
Setting top and bottom stackView constraints to >= 8 will "push" the top and bottom of the contentView, but allow extra space when you don't have enough text to exceed the vertical bounds.
Results:
Here's a storyboard with everything in place for reference:
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14109" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="SeU-GX-TTY">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="bCz-Kd-LLi">
<objects>
<viewController id="SeU-GX-TTY" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="qjW-fW-J5n">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Zj2-9M-SP5" userLabel="scrollView">
<rect key="frame" x="0.0" y="40" width="375" height="627"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Pmb-IH-ckB" userLabel="contentView">
<rect key="frame" x="0.0" y="0.0" width="375" height="627"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="EfQ-93-hcI" userLabel="stackView">
<rect key="frame" x="40" y="164" width="295" height="299.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Anger" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Sxz-f7-zjR" userLabel="topLabel">
<rect key="frame" x="0.0" y="0.0" width="295" height="43"/>
<color key="backgroundColor" red="0.99953407049999998" green="0.98835557699999999" blue="0.47265523669999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="36"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="STy-4u-e1W" userLabel="centerLabel">
<rect key="frame" x="0.0" y="63" width="295" height="183"/>
<color key="backgroundColor" red="0.83216959239999999" green="0.98548370600000001" blue="0.47333085539999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<string key="text">Anger is an intense emotion defined as a response to a perceived provocation, the invasion of one's boundaries, or a threat. From an evolutionary standpoint, anger servers to mobilise psychological resources in order to address the threat/invasion. Anger is directed at an individual of equal status.</string>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="749" text="Based on information from Wikipedia. APA DIctionary of Psycology" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="u3i-zP-e1M" userLabel="bottomLabel">
<rect key="frame" x="0.0" y="266" width="295" height="33.5"/>
<color key="backgroundColor" red="0.45138680930000002" green="0.99309605359999997" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" red="0.46202266219999999" green="0.83828371759999998" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="EfQ-93-hcI" secondAttribute="trailing" constant="40" id="4HE-oJ-RE3"/>
<constraint firstItem="EfQ-93-hcI" firstAttribute="centerY" secondItem="Pmb-IH-ckB" secondAttribute="centerY" id="H9O-jj-a7A"/>
<constraint firstItem="EfQ-93-hcI" firstAttribute="top" relation="greaterThanOrEqual" secondItem="Pmb-IH-ckB" secondAttribute="top" constant="8" id="cKe-DN-Lbn"/>
<constraint firstItem="EfQ-93-hcI" firstAttribute="leading" secondItem="Pmb-IH-ckB" secondAttribute="leading" constant="40" id="f4g-6a-VqH"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="EfQ-93-hcI" secondAttribute="bottom" constant="8" id="meR-gT-OVG"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="Pmb-IH-ckB" firstAttribute="top" secondItem="Zj2-9M-SP5" secondAttribute="top" id="HCI-bq-7ur"/>
<constraint firstAttribute="trailing" secondItem="Pmb-IH-ckB" secondAttribute="trailing" id="Tdl-c0-GAV"/>
<constraint firstItem="Pmb-IH-ckB" firstAttribute="width" secondItem="Zj2-9M-SP5" secondAttribute="width" id="Zj9-ND-Fqt"/>
<constraint firstItem="Pmb-IH-ckB" firstAttribute="leading" secondItem="Zj2-9M-SP5" secondAttribute="leading" id="ckv-wi-E1z"/>
<constraint firstItem="Pmb-IH-ckB" firstAttribute="height" secondItem="Zj2-9M-SP5" secondAttribute="height" priority="250" id="jpK-HZ-vva"/>
<constraint firstAttribute="bottom" secondItem="Pmb-IH-ckB" secondAttribute="bottom" id="psz-UW-bNp"/>
</constraints>
</scrollView>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Zj2-9M-SP5" firstAttribute="top" secondItem="Xr7-LW-bbC" secondAttribute="top" constant="20" id="EgA-Bk-3fC"/>
<constraint firstItem="Zj2-9M-SP5" firstAttribute="leading" secondItem="qjW-fW-J5n" secondAttribute="leading" id="MBG-pL-R8Q"/>
<constraint firstItem="Xr7-LW-bbC" firstAttribute="bottom" secondItem="Zj2-9M-SP5" secondAttribute="bottom" id="e9K-6A-Y9F"/>
<constraint firstItem="Xr7-LW-bbC" firstAttribute="trailing" secondItem="Zj2-9M-SP5" secondAttribute="trailing" id="yfs-wt-Br8"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Xr7-LW-bbC"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="lHx-xL-Vx5" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="225" y="106"/>
</scene>
</scenes>
</document>
And here's a quick example replicating that layout / functionality via code only:
//
// ScrollWorkViewController.swift
//
// Created by DonMag on 6/12/19.
//
import UIKit
class ScrollWorkViewController: UIViewController {
let theScrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .red
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(red: 0.25, green: 0.25, blue: 1.0, alpha: 1.0)
return v
}()
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.alignment = .fill
v.distribution = .fill
v.spacing = 20
return v
}()
let topLabel: UILabel = {
let v = UILabel()
v.font = UIFont.boldSystemFont(ofSize: 32.0)
v.backgroundColor = .yellow
return v
}()
let centerLabel: UILabel = {
let v = UILabel()
v.font = UIFont.systemFont(ofSize: 17.0)
v.numberOfLines = 0
v.backgroundColor = .green
return v
}()
let bottomLabel: UILabel = {
let v = UILabel()
v.font = UIFont.systemFont(ofSize: 14.0)
v.numberOfLines = 0
v.backgroundColor = .cyan
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
[theScrollView, contentView, stackView, topLabel, centerLabel, bottomLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(theScrollView)
theScrollView.addSubview(contentView)
contentView.addSubview(stackView)
stackView.addArrangedSubview(topLabel)
stackView.addArrangedSubview(centerLabel)
stackView.addArrangedSubview(bottomLabel)
let contentViewHeightConstraint = contentView.heightAnchor.constraint(equalTo: theScrollView.heightAnchor, constant: 0.0)
contentViewHeightConstraint.priority = .defaultLow
NSLayoutConstraint.activate([
// constrain all 4 sides of the scroll view to the safe area
theScrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0.0),
theScrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: 0.0),
theScrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0.0),
theScrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0.0),
// constrain all 4 sides of the content view to the scroll view
contentView.topAnchor.constraint(equalTo: theScrollView.topAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: theScrollView.bottomAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: theScrollView.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: theScrollView.trailingAnchor, constant: 0.0),
// constrain width of content view to width of scroll view
contentView.widthAnchor.constraint(equalTo: theScrollView.widthAnchor, constant: 0.0),
// constrain the stack view >= 8-pts from the top
// <= minus 8-pts from the bottom
// 40-pts leading and trailing
stackView.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8.0),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -8.0),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40.0),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40.0),
// constrain stack view centerY to contentView centerY
stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0),
// activate the contentView's height constraint
contentViewHeightConstraint,
])
topLabel.text = "Anger"
bottomLabel.text = "Based on information from Wikipedia APA Dictionary of Psychology"
// a sample paragraph of text
let centerSampleText = "Anger is an intense emotion defined as a response to a perceived provocation, the invasion of one’s boundaries, or a threat. From an evolutionary standpoint, anger servers to mobilise psychological resources in order to address the threat/invasion. Anger is directed at an individual of equal status."
// change to repeat the center-label sample text
let numberOfParagraphs = 2
var s = ""
for i in 1...numberOfParagraphs {
s += "\(i). " + centerSampleText
if i < numberOfParagraphs {
s += "\n\n"
}
}
centerLabel.text = s
}
}
Edit - since this answer still gets occasional "up-votes," I've updated the code to reflect the more modern usage of scroll view .contentLayoutGuide
and .frameFlayoutGuide
. Also added buttons to interactively add / remove text demonstrate the centering.
class ScrollWorkViewController: UIViewController {
let theScrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .systemYellow
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = .systemBlue
return v
}()
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.alignment = .fill
v.distribution = .fill
v.spacing = 20
return v
}()
let topLabel: UILabel = {
let v = UILabel()
v.font = UIFont.boldSystemFont(ofSize: 32.0)
v.backgroundColor = .yellow
return v
}()
let centerLabel: UILabel = {
let v = UILabel()
v.font = UIFont.systemFont(ofSize: 17.0)
v.numberOfLines = 0
v.backgroundColor = .green
return v
}()
let bottomLabel: UILabel = {
let v = UILabel()
v.font = UIFont.systemFont(ofSize: 14.0)
v.numberOfLines = 0
v.backgroundColor = .cyan
return v
}()
// a sample paragraph of text
let centerSampleText = "Anger is an intense emotion defined as a response to a perceived provocation, the invasion of one’s boundaries, or a threat. From an evolutionary standpoint, anger servers to mobilise psychological resources in order to address the threat/invasion. Anger is directed at an individual of equal status."
// update the center-label text when numberOfParagraphs changes
var numberOfParagraphs = 1 {
didSet {
var s = ""
for i in 1...numberOfParagraphs {
s += "\(i). " + centerSampleText
if i < numberOfParagraphs {
s += "\n\n"
}
}
centerLabel.text = s
}
}
override func viewDidLoad() {
super.viewDidLoad()
let btnA = UIButton()
btnA.setTitle("Add", for: [])
btnA.setTitleColor(.white, for: .normal)
btnA.setTitleColor(.lightGray, for: .highlighted)
btnA.backgroundColor = .systemGreen
let btnB = UIButton()
btnB.setTitle("Remove", for: [])
btnB.setTitleColor(.white, for: .normal)
btnB.setTitleColor(.lightGray, for: .highlighted)
btnB.backgroundColor = .systemRed
[btnA, btnB, theScrollView, contentView, stackView, topLabel, centerLabel, bottomLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
view.addSubview(btnA)
view.addSubview(btnB)
view.addSubview(theScrollView)
theScrollView.addSubview(contentView)
contentView.addSubview(stackView)
stackView.addArrangedSubview(topLabel)
stackView.addArrangedSubview(centerLabel)
stackView.addArrangedSubview(bottomLabel)
let g = view.safeAreaLayoutGuide
let cg = theScrollView.contentLayoutGuide
let fg = theScrollView.frameLayoutGuide
// constrain height of content view to height of scroll view's Frame Layout Guide
// with less-than-required Priority so it can get taller when the content gets taller
let contentViewHeightConstraint = contentView.heightAnchor.constraint(equalTo: fg.heightAnchor, constant: 0.0)
contentViewHeightConstraint.priority = .defaultLow
NSLayoutConstraint.activate([
// constrain buttons at top
btnA.topAnchor.constraint(equalTo: g.topAnchor, constant: 8.0),
btnA.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
btnB.topAnchor.constraint(equalTo: g.topAnchor, constant: 8.0),
btnB.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
btnB.leadingAnchor.constraint(equalTo: btnA.trailingAnchor, constant: 20.0),
btnB.widthAnchor.constraint(equalTo: btnA.widthAnchor),
// constrain scroll view Top to buttons Bottom plus 8-points "spacing"
// leading/trailing/bottom to the safe area
theScrollView.topAnchor.constraint(equalTo: btnA.bottomAnchor, constant: 8.0),
theScrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
theScrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
theScrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain all 4 sides of the content view to the scroll view's Content Layout Guide
contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
// constrain width of content view to width of scroll view's Frame Layout Guide
contentView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: 0.0),
// constrain the stack view >= 8-pts from the top
// <= minus 8-pts from the bottom
// 40-pts leading and trailing
stackView.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 8.0),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -8.0),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40.0),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40.0),
// constrain stack view centerY to contentView centerY
stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0),
// activate the contentView's height constraint
contentViewHeightConstraint,
])
topLabel.text = "Anger"
bottomLabel.text = "Based on information from Wikipedia APA Dictionary of Psychology"
numberOfParagraphs = 1
btnA.addTarget(self, action: #selector(addTapped(_:)), for: .touchUpInside)
btnB.addTarget(self, action: #selector(removeTapped(_:)), for: .touchUpInside)
}
@objc func addTapped(_ sender: Any?) {
numberOfParagraphs += 1
}
@objc func removeTapped(_ sender: Any?) {
if numberOfParagraphs > 1 {
numberOfParagraphs -= 1
}
}
}
With one or two paragraphs, the content is not tall enough to scroll, but remains vertically centered:
With 3 or more paragraphs (on an iPhone SE), we can now scroll: