How would I create an UIView / UILabel with vertical text flow which would look like the red view of this example screen?
I have read about view.transform = CGAffineTransform(... which allows for easy rotation, BUT it would break the auto-layout constraints.
I would be happy to use a third-party library, but I cannot find any.
As noted in Apple's docs:
In iOS 8.0 and later, the transform property does not affect Auto Layout. Auto layout calculates a view’s alignment rectangle based on its untransformed frame.
So, to get transformed views to "play nice" with auto layout, we need to - in effect - tell constraints to use the opposite axis.
For example, if we embed a UILabel in a UIView and rotate the label 90-degrees, we want to constrain the "container" view's Width to the label's Height and its Height to the label's Width.
Here's a sample VerticalLabelView view subclass:
class VerticalLabelView: UIView {
public var numberOfLines: Int = 1 {
didSet {
label.numberOfLines = numberOfLines
}
}
public var text: String = "" {
didSet {
label.text = text
}
}
// vertical and horizontal "padding"
// defaults to 16-ps (8-pts on each side)
public var vPad: CGFloat = 16.0 {
didSet {
h.constant = vPad
}
}
public var hPad: CGFloat = 16.0 {
didSet {
w.constant = hPad
}
}
// because the label is rotated, we need to swap the axis
override func setContentHuggingPriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis) {
label.setContentHuggingPriority(priority, for: axis == .horizontal ? .vertical : .horizontal)
}
// this is just for development
// show/hide border of label
public var showBorder: Bool = false {
didSet {
label.layer.borderWidth = showBorder ? 1 : 0
label.layer.borderColor = showBorder ? UIColor.red.cgColor : UIColor.clear.cgColor
}
}
public let label = UILabel()
private var w: NSLayoutConstraint!
private var h: NSLayoutConstraint!
private var mh: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
addSubview(label)
label.backgroundColor = .clear
label.translatesAutoresizingMaskIntoConstraints = false
// rotate 90-degrees
let angle = .pi * 0.5
label.transform = CGAffineTransform(rotationAngle: angle)
// so we can change the "padding" dynamically
w = self.widthAnchor.constraint(equalTo: label.heightAnchor, constant: hPad)
h = self.heightAnchor.constraint(equalTo: label.widthAnchor, constant: vPad)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: self.centerXAnchor),
label.centerYAnchor.constraint(equalTo: self.centerYAnchor),
w, h,
])
}
}
I've added a few properties to allow the view to be treated like a label, so we can do:
let v = VerticalLabelView()
// "pass-through" properties
v.text = "Some text which will be put into the label."
v.numberOfLines = 0
// directly setting properties
v.label.textColor = .red
This could, of course, be extended to "pass through" all label properties we need to use so we wouldn't need to reference the .label directly.
This VerticalLabelView can now be used much like a normal UILabel.
Here are two examples - they both use this BaseVC to setup the views:
class BaseVC: UIViewController {
let greenView: UIView = {
let v = UIView()
v.backgroundColor = .green
return v
}()
let normalLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
return v
}()
let lYellow: VerticalLabelView = {
let v = VerticalLabelView()
v.backgroundColor = UIColor(red: 1.0, green: 1.0, blue: 0.5, alpha: 1.0)
v.numberOfLines = 0
return v
}()
let lRed: VerticalLabelView = {
let v = VerticalLabelView()
v.backgroundColor = UIColor(red: 1.0, green: 0.5, blue: 0.5, alpha: 1.0)
v.numberOfLines = 0
return v
}()
let lBlue: VerticalLabelView = {
let v = VerticalLabelView()
v.backgroundColor = UIColor(red: 0.3, green: 0.8, blue: 1.0, alpha: 1.0)
v.numberOfLines = 1
return v
}()
let container: UIView = {
let v = UIView()
v.backgroundColor = .systemYellow
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
let strs: [String] = [
"Multiline Vertical Text",
"Vertical Text",
"Overflow Vertical Text",
]
// default UILabel
normalLabel.text = "Regular UILabel wrapping text"
// add the normal label to the green view
greenView.addSubview(normalLabel)
// set text of vertical labels
for (s, v) in zip(strs, [lYellow, lRed, lBlue]) {
v.text = s
}
[container, greenView, normalLabel, lYellow, lRed, lBlue].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
// add greenView to the container
container.addSubview(greenView)
// add container to self's view
view.addSubview(container)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain container Top and CenterX
container.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
container.centerXAnchor.constraint(equalTo: g.centerXAnchor),
// comment next line to allow container subviews to set the height
container.heightAnchor.constraint(equalToConstant: 260.0),
// comment next line to allow container subviews to set the width
container.widthAnchor.constraint(equalToConstant: 160.0),
// green view at Top, stretched full width
greenView.topAnchor.constraint(equalTo: container.topAnchor, constant: 0.0),
greenView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
greenView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
// constrain normal label in green view
// with 8-pts "padding" on all 4 sides
normalLabel.topAnchor.constraint(equalTo: greenView.topAnchor, constant: 8.0),
normalLabel.leadingAnchor.constraint(equalTo: greenView.leadingAnchor, constant: 8.0),
normalLabel.trailingAnchor.constraint(equalTo: greenView.trailingAnchor, constant: -8.0),
normalLabel.bottomAnchor.constraint(equalTo: greenView.bottomAnchor, constant: -8.0),
])
}
}
The first example - SubviewsExampleVC - adds each as a subview, and then we add constraints between the views:
class SubviewsExampleVC: BaseVC {
override func viewDidLoad() {
super.viewDidLoad()
// add vertical labels to the container
[lYellow, lRed, lBlue].forEach { v in
container.addSubview(v)
}
NSLayoutConstraint.activate([
// yellow label constrained to Bottom of green view
lYellow.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
// Leading to container Leading
lYellow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
// red label constrained to Bottom of green view
lRed.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
// Leading to yellow label Trailing
lRed.leadingAnchor.constraint(equalTo: lYellow.trailingAnchor, constant: 0.0),
// blue label constrained to Bottom of green view
lBlue.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
// Leading to red label Trailing
lBlue.leadingAnchor.constraint(equalTo: lRed.trailingAnchor, constant: 0.0),
// if we want the labels to fill the container width
// blue label Trailing constrained to container Trailing
lBlue.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
// using constraints to set the vertical label heights
lYellow.heightAnchor.constraint(equalToConstant: 132.0),
lRed.heightAnchor.constraint(equalTo: lYellow.heightAnchor),
lBlue.heightAnchor.constraint(equalTo: lYellow.heightAnchor),
])
// as always, we need to control which view(s)
// hug their content
// so, for example, if we want the Yellow label to "stretch" horizontally
lRed.setContentHuggingPriority(.required, for: .horizontal)
lBlue.setContentHuggingPriority(.required, for: .horizontal)
// or, for example, if we want the Red label to "stretch" horizontally
//lYellow.setContentHuggingPriority(.required, for: .horizontal)
//lBlue.setContentHuggingPriority(.required, for: .horizontal)
}
}
The second example = StackviewExampleVC - adds each as an arranged subview of a UIStackView:
class StackviewExampleVC: BaseVC {
override func viewDidLoad() {
super.viewDidLoad()
// horizontal stack view
let stackView = UIStackView()
// add vertical labels to the stack view
[lYellow, lRed, lBlue].forEach { v in
stackView.addArrangedSubview(v)
}
stackView.translatesAutoresizingMaskIntoConstraints = false
// add stack view to container
container.addSubview(stackView)
NSLayoutConstraint.activate([
// constrain stack view Top to green view Bottom
stackView.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
// Leading / Trailing to container Leading / Trailing
stackView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
// stack view height
stackView.heightAnchor.constraint(equalToConstant: 132.0),
])
// as always, we need to control which view(s)
// hug their content
// so, for example, if we want the Yellow label to "stretch" horizontally
lRed.setContentHuggingPriority(.required, for: .horizontal)
lBlue.setContentHuggingPriority(.required, for: .horizontal)
// or, for example, if we want the Red label to "stretch" horizontally
//lYellow.setContentHuggingPriority(.required, for: .horizontal)
//lBlue.setContentHuggingPriority(.required, for: .horizontal)
}
}
Both examples produce this output:
Please note: this is Example Code Only - it is not intended to be, nor should it be considered to be, Production Ready
Related
Forgive me if explanation is not excellent. Basically, the video below shows the standard animation for hiding labels in a stack view. Notice it looks like the labels "slide" and "collapse together".
I still want to hide the labels, but want an animation where the alpha changes but the labels don't "slide". Instead, the labels change alpha and stay in place. Is this possible with stack views?
This is the code I have to animate:
UIView.animate(withDuration: 0.5) {
if self.isExpanded {
self.topLabel.alpha = 1.0
self.bottomLabel.alpha = 1.0
self.topLabel.isHidden = false
self.bottomLabel.isHidden = false
} else {
self.topLabel.alpha = 0.0
self.bottomLabel.alpha = 0.0
self.topLabel.isHidden = true
self.bottomLabel.isHidden = true
}
}
Update 1
It seems that even without a stack view, if I animate the height constraint, you get this "squeeze" effect. Example:
UIView.animate(withDuration: 3.0) {
self.heightConstraint.constant = 20
self.view.layoutIfNeeded()
}
Here are a couple options:
Set .contentMode = .top on the labels. I've never found Apple docs that clearly describe using .contentMode with UILabel, but it works and should work.
Embed the label in a UIView, constrained to the top, with Content Compression Resistance Priority set to .required, less-than-required priority for the bottom constraint, and .clipsToBounds = true on the view.
Example 1 - content mode:
class StackAnimVC: UIViewController {
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 0
return v
}()
let topLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
return v
}()
let botLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
return v
}()
let headerLabel = UILabel()
let threeLabel = UILabel()
let footerLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
// label setup
let colors: [UIColor] = [
.systemYellow,
.cyan,
UIColor(red: 1.0, green: 0.85, blue: 0.9, alpha: 1.0),
UIColor(red: 0.7, green: 0.5, blue: 0.4, alpha: 1.0),
UIColor(white: 0.9, alpha: 1.0),
]
for (c, v) in zip(colors, [headerLabel, topLabel, botLabel, threeLabel, footerLabel]) {
v.backgroundColor = c
v.font = .systemFont(ofSize: 24.0, weight: .light)
stackView.addArrangedSubview(v)
}
headerLabel.text = "Header"
threeLabel.text = "Three"
footerLabel.text = "Footer"
topLabel.text = "It seems that even without a stack view, if I animate the height constraint, you get this \"squeeze\" effect."
botLabel.text = "I still want to hide the labels, but want an animation where the alpha changes but the labels don't \"slide\"."
// we want 8-pts "padding" under the "collapsible" labels
stackView.setCustomSpacing(8.0, after: topLabel)
stackView.setCustomSpacing(8.0, after: botLabel)
// let's add a label and a Switch to toggle the labels .contentMode
let promptView = UIView()
let hStack = UIStackView()
hStack.spacing = 8
let prompt = UILabel()
prompt.text = "Content Mode Top:"
prompt.textAlignment = .right
let sw = UISwitch()
sw.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
hStack.addArrangedSubview(prompt)
hStack.addArrangedSubview(sw)
hStack.translatesAutoresizingMaskIntoConstraints = false
promptView.addSubview(hStack)
// add an Animate button
let btn = UIButton(type: .system)
btn.setTitle("Animate", for: [])
btn.titleLabel?.font = .systemFont(ofSize: 24.0, weight: .regular)
btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
let g = view.safeAreaLayoutGuide
// add elements to view and give them all the same Leading and Trailing constraints
[promptView, stackView, btn].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
NSLayoutConstraint.activate([
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
}
NSLayoutConstraint.activate([
promptView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
stackView.topAnchor.constraint(equalTo: promptView.bottomAnchor, constant: 0.0),
// center the hStack in the promptView
hStack.centerXAnchor.constraint(equalTo: promptView.centerXAnchor),
hStack.centerYAnchor.constraint(equalTo: promptView.centerYAnchor),
promptView.heightAnchor.constraint(equalTo: hStack.heightAnchor, constant: 16.0),
// put button near bottom
btn.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
])
}
#objc func switchChanged(_ sender: UISwitch) {
[topLabel, botLabel].forEach { v in
v.contentMode = sender.isOn ? .top : .left
}
}
#objc func btnTap(_ sender: UIButton) {
UIView.animate(withDuration: 0.5) {
// toggle hidden and alpha on stack view labels
self.topLabel.alpha = self.topLabel.isHidden ? 1.0 : 0.0
self.botLabel.alpha = self.botLabel.isHidden ? 1.0 : 0.0
self.topLabel.isHidden.toggle()
self.botLabel.isHidden.toggle()
}
}
}
Example 2 - label embedded in a UIView:
class TopAlignedLabelView: UIView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
self.addSubview(label)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: topAnchor, constant: 0.0),
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
])
// we need bottom anchor to have
// less-than-required Priority
let c = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
c.priority = .required - 1
c.isActive = true
// don't allow label to be compressed
label.setContentCompressionResistancePriority(.required, for: .vertical)
// we need to clip the label
self.clipsToBounds = true
}
}
class StackAnimVC: UIViewController {
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 0
return v
}()
let topLabel: TopAlignedLabelView = {
let v = TopAlignedLabelView()
return v
}()
let botLabel: TopAlignedLabelView = {
let v = TopAlignedLabelView()
return v
}()
let headerLabel = UILabel()
let threeLabel = UILabel()
let footerLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
// label setup
let colors: [UIColor] = [
.systemYellow,
.cyan,
UIColor(red: 1.0, green: 0.85, blue: 0.9, alpha: 1.0),
UIColor(red: 0.7, green: 0.5, blue: 0.4, alpha: 1.0),
UIColor(white: 0.9, alpha: 1.0),
]
for (c, v) in zip(colors, [headerLabel, topLabel, botLabel, threeLabel, footerLabel]) {
v.backgroundColor = c
if let vv = v as? UILabel {
vv.font = .systemFont(ofSize: 24.0, weight: .light)
}
if let vv = v as? TopAlignedLabelView {
vv.label.font = .systemFont(ofSize: 24.0, weight: .light)
}
stackView.addArrangedSubview(v)
}
headerLabel.text = "Header"
threeLabel.text = "Three"
footerLabel.text = "Footer"
topLabel.label.text = "It seems that even without a stack view, if I animate the height constraint, you get this \"squeeze\" effect."
botLabel.label.text = "I still want to hide the labels, but want an animation where the alpha changes but the labels don't \"slide\"."
// we want 8-pts "padding" under the "collapsible" labels
stackView.setCustomSpacing(8.0, after: topLabel)
stackView.setCustomSpacing(8.0, after: botLabel)
// add an Animate button
let btn = UIButton(type: .system)
btn.setTitle("Animate", for: [])
btn.titleLabel?.font = .systemFont(ofSize: 24.0, weight: .regular)
btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
let g = view.safeAreaLayoutGuide
// add elements to view and give them all the same Leading and Trailing constraints
[stackView, btn].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
NSLayoutConstraint.activate([
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
}
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
// put button near bottom
btn.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
])
}
#objc func btnTap(_ sender: UIButton) {
UIView.animate(withDuration: 0.5) {
// toggle hidden and alpha on stack view labels
self.topLabel.alpha = self.topLabel.isHidden ? 1.0 : 0.0
self.botLabel.alpha = self.botLabel.isHidden ? 1.0 : 0.0
self.topLabel.isHidden.toggle()
self.botLabel.isHidden.toggle()
}
}
}
Edit
If your goal is to have the Brown label "slide up and cover" both the Blue and Pink labels, with neither of those labels compressing or moving, take a similar approach:
use standard UILabel instead of the TopAlignedLabelView
embed the Blue and Pink labels in their own stack view
embed that stack view in a "container" view
constrain that stack view to be "top-aligned" like we did with the label in the TopAlignedLabelView
The arranged subviews of the "outer" stack view will now be:
Yellow label
"container" view
Brown label
Gray label
and to animate we'll toggle the .alpha and .isHidden on the "container" view instead of the Blue and Pink labels.
I edited the controller class -- give it a try and see if that's the effect you're after.
If it is, I strongly suggest you try to make those changes yourself... if you run into problems, use this example code as a guide:
class StackAnimVC: UIViewController {
let outerStackView: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 0
return v
}()
// create an "inner" stack view
// this will hold topLabel and botLabel
let innerStackView: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.spacing = 8
return v
}()
// container for the inner stack view
let innerStackContainer: UIView = {
let v = UIView()
v.clipsToBounds = true
return v
}()
// we can use standard UILabels instead of custom views
let topLabel = UILabel()
let botLabel = UILabel()
let headerLabel = UILabel()
let threeLabel = UILabel()
let footerLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
// label setup
let colors: [UIColor] = [
.systemYellow,
.cyan,
UIColor(red: 1.0, green: 0.85, blue: 0.9, alpha: 1.0),
UIColor(red: 0.7, green: 0.5, blue: 0.4, alpha: 1.0),
UIColor(white: 0.9, alpha: 1.0),
]
for (c, v) in zip(colors, [headerLabel, topLabel, botLabel, threeLabel, footerLabel]) {
v.backgroundColor = c
v.font = .systemFont(ofSize: 24.0, weight: .light)
v.setContentCompressionResistancePriority(.required, for: .vertical)
}
// add top and bottom labels to inner stack view
innerStackView.addArrangedSubview(topLabel)
innerStackView.addArrangedSubview(botLabel)
// add inner stack view to container
innerStackView.translatesAutoresizingMaskIntoConstraints = false
innerStackContainer.addSubview(innerStackView)
// constraints for inner stack view
// bottom constraint must be less-than-required
// so it doesn't compress when the container compresses
let isvBottom: NSLayoutConstraint = innerStackView.bottomAnchor.constraint(equalTo: innerStackContainer.bottomAnchor, constant: -8.0)
isvBottom.priority = .defaultHigh
NSLayoutConstraint.activate([
innerStackView.topAnchor.constraint(equalTo: innerStackContainer.topAnchor, constant: 0.0),
innerStackView.leadingAnchor.constraint(equalTo: innerStackContainer.leadingAnchor, constant: 0.0),
innerStackView.trailingAnchor.constraint(equalTo: innerStackContainer.trailingAnchor, constant: 0.0),
isvBottom,
])
topLabel.numberOfLines = 0
botLabel.numberOfLines = 0
topLabel.text = "It seems that even without a stack view, if I animate the height constraint, you get this \"squeeze\" effect."
botLabel.text = "I still want to hide the labels, but want an animation where the alpha changes but the labels don't \"slide\"."
headerLabel.text = "Header"
threeLabel.text = "Three"
footerLabel.text = "Footer"
// add views to outer stack view
[headerLabel, innerStackContainer, threeLabel, footerLabel].forEach { v in
outerStackView.addArrangedSubview(v)
}
// add an Animate button
let btn = UIButton(type: .system)
btn.setTitle("Animate", for: [])
btn.titleLabel?.font = .systemFont(ofSize: 24.0, weight: .regular)
btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
let g = view.safeAreaLayoutGuide
// add elements to view and give them all the same Leading and Trailing constraints
[outerStackView, btn].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
NSLayoutConstraint.activate([
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
}
NSLayoutConstraint.activate([
outerStackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
// put button near bottom
btn.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
])
}
#objc func btnTap(_ sender: UIButton) {
UIView.animate(withDuration: 0.5) {
// toggle hidden and alpha on inner stack container
self.innerStackContainer.alpha = self.innerStackContainer.isHidden ? 1.0 : 0.0
self.innerStackContainer.isHidden.toggle()
}
}
}
Edit 2
A quick explanation of why this works...
Consider a typical UILabel as a subview of a UIView. We constrain the label to the view on all 4 sides with a little "padding":
aLabel.topAnchor.constraint(equalTo: aView.topAnchor, constant: 8.0),
aLabel.leadingAnchor.constraint(equalTo: aView.leadingAnchor, constant: 8.0),
aLabel.trailingAnchor.constraint(equalTo: aView.trailingAnchor, constant: -8.0),
aLabel.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: -8.0),
Now we can constrain the view's Top / Leading / Trailing -- but not Bottom or Height -- and the label's intrinsic Height will control the Height of the view.
Pretty basic.
But, if we want to "animate it out of existence," changing the Height of the view will also change the Height of the label, resulting in a "squeeze" effect. We'll also get auto-layout complaints, because the constraints cannot be satisfied.
So, we need to change the .priority of the label's Bottom constraint to allow it to remain at its intrinsic Height, while its superview's Height changes.
Each of these 4 examples uses the same Top / Leading / Trailing constraints... the only difference is what we do with the Bottom constraint:
For Example 1, we don't set any Bottom constraint. So, we never even see its superview and animating the Height of its superview has no effect on the label.
For Example 2, we set the "normal" Bottom constraint, and we see the "squeezing" effect.
For Example 3, we give the label's Bottom constraint .priority = .defaultHigh. The label still controls the Height of its superview... until we activate the superview's Height constraint (of zero). The superview collapses, but we've given auto-layout permission to break the Bottom constraint.
Example 4 is the same as 3, but we've also set .clipsToBounds = true on the container view so the label Height remains constant, but no longer extends outside its superview.
All of that also applies to views in a stack view when setting .isHidden on an arranged subview.
Here's the code that generates that example, if you want to inspect it and play around with the variations:
class DemoVC: UIViewController {
var containerViews: [UIView] = []
var heightConstraints: [NSLayoutConstraint] = []
override func viewDidLoad() {
super.viewDidLoad()
let g = view.safeAreaLayoutGuide
// create 4 container views, each with a label as a subview
let colors: [UIColor] = [
.systemRed, .systemGreen, .systemBlue, .systemYellow,
]
colors.forEach { bkgColor in
let thisContainer = UIView()
thisContainer.translatesAutoresizingMaskIntoConstraints = false
let thisLabel = UILabel()
thisLabel.translatesAutoresizingMaskIntoConstraints = false
thisContainer.backgroundColor = bkgColor
thisLabel.backgroundColor = UIColor(red: 0.75, green: 0.9, blue: 1.0, alpha: 1.0)
thisLabel.numberOfLines = 0
//thisLabel.font = .systemFont(ofSize: 20.0, weight: .light)
thisLabel.font = .systemFont(ofSize: 12.0, weight: .light)
thisLabel.text = "We want to animate compressing the \"container\" view vertically, without it squeezing or moving this label."
// add label to container view
thisContainer.addSubview(thisLabel)
// add container view to array
containerViews.append(thisContainer)
// add container view to view
view.addSubview(thisContainer)
NSLayoutConstraint.activate([
// each example gets the label constrained
// Top / Leading / Trailing to its container view
thisLabel.topAnchor.constraint(equalTo: thisContainer.topAnchor, constant: 8.0),
thisLabel.leadingAnchor.constraint(equalTo: thisContainer.leadingAnchor, constant: 8.0),
thisLabel.trailingAnchor.constraint(equalTo: thisContainer.trailingAnchor, constant: -8.0),
// we'll be using different bottom constraints for the examples,
// so don't set it here
//thisLabel.bottomAnchor.constraint(equalTo: thisContainer.bottomAnchor, constant: -8.0),
// each container view gets constrained to the top
thisContainer.topAnchor.constraint(equalTo: g.topAnchor, constant: 60.0),
])
// setup the container view height constraints, but don't activate them
let hc = thisContainer.heightAnchor.constraint(equalToConstant: 0.0)
// add the constraint to the constraints array
heightConstraints.append(hc)
}
// couple vars to reuse
var prevContainer: UIView!
var aContainer: UIView!
var itsLabel: UIView!
var bc: NSLayoutConstraint!
// -------------------------------------------------------------------
// first example
// we don't add a bottom constraint for the label
// that means we'll never see its container view
// and changing its height constraint won't do anything to the label
// -------------------------------------------------------------------
// second example
aContainer = containerViews[1]
itsLabel = aContainer.subviews.first
// we'll add a "standard" bottom constraint
// so now we see its container view
bc = itsLabel.bottomAnchor.constraint(equalTo: aContainer.bottomAnchor, constant: -8.0)
bc.isActive = true
// -------------------------------------------------------------------
// third example
aContainer = containerViews[2]
itsLabel = aContainer.subviews.first
// add the same bottom constraint, but give it a
// less-than-required Priority so it won't "squeeze"
bc = itsLabel.bottomAnchor.constraint(equalTo: aContainer.bottomAnchor, constant: -8.0)
bc.priority = .defaultHigh
bc.isActive = true
// -------------------------------------------------------------------
// fourth example
aContainer = containerViews[3]
itsLabel = aContainer.subviews.first
// same less-than-required Priority bottom constraint,
bc = itsLabel.bottomAnchor.constraint(equalTo: aContainer.bottomAnchor, constant: -8.0)
bc.priority = .defaultHigh
bc.isActive = true
// we'll also set clipsToBounds on the container view
// so it will "hide / reveal" the label
aContainer.clipsToBounds = true
// now we need to layout the views
// constrain first example leading
aContainer = containerViews[0]
aContainer.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0).isActive = true
prevContainer = aContainer
for i in 1..<containerViews.count {
aContainer = containerViews[i]
aContainer.leadingAnchor.constraint(equalTo: prevContainer.trailingAnchor, constant: 8.0).isActive = true
aContainer.widthAnchor.constraint(equalTo: prevContainer.widthAnchor).isActive = true
prevContainer = aContainer
}
// constrain last example trailing
prevContainer.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0).isActive = true
// and, let's add labels above the 4 examples
for (i, v) in containerViews.enumerated() {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Example \(i + 1)"
label.font = .systemFont(ofSize: 14.0, weight: .light)
view.addSubview(label)
NSLayoutConstraint.activate([
label.bottomAnchor.constraint(equalTo: v.topAnchor, constant: -4.0),
label.centerXAnchor.constraint(equalTo: v.centerXAnchor),
])
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
heightConstraints.forEach { c in
c.isActive = !c.isActive
}
UIView.animate(withDuration: 1.0, animations: {
self.view.layoutIfNeeded()
})
}
}
What I am trying to achieve (and I am not 100% sure how to do it or how to explain it properly) is described in the below screenshot.
I have added allowsDefaultTighteningForTruncation = true and lineBreakMode = .byClipping to my label, but it now displays the beginning of the word and I need to display the end of the word, any ideas on how to achieve that? or any ideas what to look for in apple docs? I've read everything I could think of so far.
To get that result, you need to embed the label in a UIView and constrain the label's Trailing but not Leading.
Make sure the "holder" view has Clips To Bounds set to true.
As the label grows in width, it will extend past the leading edge of the holder view.
Here's a quick example:
class ViewController: UIViewController {
let theLabel = UILabel()
let holderView = UIView()
let strs: [String] = [
"Misinterpret",
"Misinterpreted",
"Misinterpretation",
]
var idx = 0
override func viewDidLoad() {
super.viewDidLoad()
theLabel.translatesAutoresizingMaskIntoConstraints = false
holderView.translatesAutoresizingMaskIntoConstraints = false
holderView.backgroundColor = .systemBlue
theLabel.backgroundColor = .yellow
theLabel.font = .systemFont(ofSize: 30.0)
holderView.addSubview(theLabel)
view.addSubview(holderView)
NSLayoutConstraint.activate([
holderView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
holderView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
holderView.widthAnchor.constraint(equalToConstant: 200.0),
holderView.heightAnchor.constraint(equalTo: theLabel.heightAnchor, constant: 8.0),
theLabel.centerYAnchor.constraint(equalTo: holderView.centerYAnchor),
theLabel.trailingAnchor.constraint(equalTo: holderView.trailingAnchor, constant: -8.0),
])
// clip theLabel when it gets too wide
holderView.clipsToBounds = true
theLabel.text = strs[idx]
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
idx += 1
theLabel.text = strs[idx % strs.count]
}
}
Output:
The "type ahead" suggestion bar probably also uses a gradient mask so the text does not look so abruptly clipped... but that's another question.
Edit - here's a more complete example.
textField at the top
label in a gray "holder" view
green label showing actual size of text
As you enter text, the labels will update.
The label in the gray box will be centered horizontally, until it is too wide to fit, at which point it will stay "right-aligned." It will also have a slight gradient mask at the left edge so it is not cut off abruptly.
class ViewController: UIViewController {
let textField = UITextField()
let theClippedLabel = UILabel()
let holderView = UIView()
// plain label showing the actual size
let theActualLabel = UILabel()
let leftEdgeFadeMask = CAGradientLayer()
override func viewDidLoad() {
super.viewDidLoad()
[textField, theClippedLabel, holderView, theActualLabel].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
holderView.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
theClippedLabel.backgroundColor = .clear
theActualLabel.backgroundColor = .green
textField.borderStyle = .roundedRect
textField.placeholder = "Type here..."
textField.addTarget(self, action: #selector(didEdit(_:)), for: .editingChanged)
theClippedLabel.font = .systemFont(ofSize: 30.0)
theActualLabel.font = theClippedLabel.font
holderView.addSubview(theClippedLabel)
view.addSubview(holderView)
view.addSubview(theActualLabel)
view.addSubview(textField)
// center label horizontally, unless it is wider than holderView (minus Left/Right "padding")
let cx = theClippedLabel.centerXAnchor.constraint(equalTo: holderView.centerXAnchor)
cx.priority = .defaultHigh
NSLayoutConstraint.activate([
// center a 200-pt wide "holder" view
holderView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
holderView.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 20.0),
holderView.widthAnchor.constraint(equalToConstant: 200.0),
// holderView height is 16-pts taller than the label height (8-pts Top / Bottom "padding")
holderView.heightAnchor.constraint(equalTo: theClippedLabel.heightAnchor, constant: 16.0),
// center the label vertically
theClippedLabel.centerYAnchor.constraint(equalTo: holderView.centerYAnchor),
// keep the label's Trailing edge at least 8-pts from the holderView's Trailing edge
theClippedLabel.trailingAnchor.constraint(lessThanOrEqualTo: holderView.trailingAnchor, constant: -8.0),
// activate cx constraint
cx,
theActualLabel.topAnchor.constraint(equalTo: holderView.bottomAnchor, constant: 4.0),
theActualLabel.centerXAnchor.constraint(equalTo: holderView.centerXAnchor),
textField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12.0),
textField.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12.0),
textField.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12.0),
])
// clip theLabel when it gets too wide
holderView.clipsToBounds = true
// gradient mask for left-edge of label
leftEdgeFadeMask.colors = [UIColor.clear.cgColor, UIColor.black.cgColor]
leftEdgeFadeMask.startPoint = CGPoint(x: 0.0, y: 0.0)
leftEdgeFadeMask.endPoint = CGPoint(x: 1.0, y: 0.0)
leftEdgeFadeMask.locations = [0.0, 0.1]
theClippedLabel.layer.mask = leftEdgeFadeMask
// so we have something to see when we start
theClippedLabel.text = " "
theActualLabel.text = theClippedLabel.text
}
#objc func didEdit(_ sender: Any) {
// if the textField is empty, use a space character so
// the labels don't disappear
var str = " "
if let s = textField.text, !s.isEmpty {
str = s
}
theClippedLabel.text = str
theActualLabel.text = str
updateMask()
}
func updateMask() -> Void {
// update label frame
theClippedLabel.sizeToFit()
// we want the gradient mask to start at the leading edge
// of the holder view, with
// 4-pts Left and 8-pts Right "padding"
var r = holderView.bounds
let targetW = r.width - 12
r.size.width -= 12
r.size.height -= 16
r.origin.x = theClippedLabel.bounds.width - targetW
// disable built-in layer animations
CATransaction.begin()
CATransaction.setDisableActions(true)
leftEdgeFadeMask.frame = r
CATransaction.commit()
}
}
Example result:
Note that this is example code only. In practical use, we'd want to build this as a custom view with all of the sizing and gradient mask logic self-contained.
I want to apply gradient layer on top 10% and bottom 10% of UITextView. To do this, I place a dummy UIView called container view and make UITextView a subview of it. And then I add the following code:
if let containerView = textView.superview {
let gradient = CAGradientLayer(layer: containerView.layer)
gradient.frame = containerView.bounds
gradient.colors = [UIColor.clear.cgColor, UIColor.black.cgColor]
gradient.locations = [0.0, 0.1, 0.9, 1.0]
containerView.layer.mask = gradient
}
But the gradient is only applied to the top, not the bottom. Is there something wrong with the code?
Further, if I resize the container view anytime by modifying it's constraints, do I need to edit the mask layer every time?
Edit: Here is the output from #DonMag answer.
But what I want is something like in this image that text fades at the bottom.
EDIT2:
Here are screenshots after DonMag's revised answer.
#DongMag solution is very complicated. Instead, you just need a mask implemented like:
#IBDesignable
class MaskableLabel: UILabel {
var maskImageView = UIImageView()
#IBInspectable
var maskImage: UIImage? {
didSet {
maskImageView.image = maskImage
updateView()
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateView()
}
func updateView() {
if maskImageView.image != nil {
maskImageView.frame = bounds
mask = maskImageView
}
}
}
Then with a simple gradient mask like this, You can see it even right in the storyboard.
Note: You can use this method and replace UILabel with any other view you like to subclass.
Here is the example project on the GitHub
Edit - after clarification of desired effect...
My initial answer as to why you were only seeing the gradient on the top stands:
You're only seeing the gradient on the top because you gave it four locations but only two colors.
So, now that you provided an image of what you're trying to do...
Use this DoubleGradientMaskView as the "container" view for the text view:
class DoubleGradientMaskView: UIView {
let gradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
gradientLayer.colors = [UIColor.clear.cgColor, UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor]
gradientLayer.locations = [0.0, 0.1, 0.9, 1.0]
layer.mask = gradientLayer
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
}
Example controller:
class GradientTextViewViewController: UIViewController {
let textView = UITextView()
let containerView = DoubleGradientMaskView()
let bkgImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
[bkgImageView, textView, containerView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
bkgImageView.contentMode = .scaleAspectFill
if let img = UIImage(named: "background") {
bkgImageView.image = img
} else {
bkgImageView.backgroundColor = .blue
}
view.addSubview(bkgImageView)
view.addSubview(containerView)
containerView.addSubview(textView)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// add an image view so we can see the white text
bkgImageView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
bkgImageView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
bkgImageView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
bkgImageView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constraint text view inside container
textView.topAnchor.constraint(equalTo: containerView.topAnchor),
textView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
textView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// constrain container Top / Bottom 40, Leading / Trailing 40
containerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
containerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
containerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
containerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
])
textView.isScrollEnabled = true
textView.font = UIFont.systemFont(ofSize: 48.0, weight: .bold)
textView.textColor = .white
textView.backgroundColor = .clear
textView.text = String((1...20).flatMap { "This is row \($0)\n" })
}
}
Result:
or, with a blue background instead of an image:
You're only seeing the gradient on the top because you gave it four locations but only two colors.
Changing the colors to:
gradient.colors = [UIColor.clear.cgColor, UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor]
would probably give you the appearance you want... but you'd need additional code to handle size changing.
If you use this class as your "container" view, sizing will be automatic:
class DoubleGradientView: UIView {
var gradientLayer: CAGradientLayer!
override class var layerClass: AnyClass {
return CAGradientLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
gradientLayer = self.layer as? CAGradientLayer
gradientLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor, UIColor.clear.cgColor, UIColor.black.cgColor]
gradientLayer.locations = [0.0, 0.1, 0.9, 1.0]
}
}
Here is an example controller. It creates two "text views in containers."
The top one is scrollable, with a height of 100.
The bottom one is NOT scrollable, so it will size its height to the text as you type.
Both are constrained Leading / Trailing at 60-pts, so you'll also see the automatic gradient update when you rotate the device.
class GradientBehindTextViewViewController: UIViewController {
let textView1 = UITextView()
let containerView1 = DoubleGradientView()
let textView2 = UITextView()
let containerView2 = DoubleGradientView()
override func viewDidLoad() {
super.viewDidLoad()
[textView1, containerView1, textView2, containerView2].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
containerView1.addSubview(textView1)
view.addSubview(containerView1)
containerView2.addSubview(textView2)
view.addSubview(containerView2)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constraint text view inside container
textView1.topAnchor.constraint(equalTo: containerView1.topAnchor),
textView1.leadingAnchor.constraint(equalTo: containerView1.leadingAnchor),
textView1.trailingAnchor.constraint(equalTo: containerView1.trailingAnchor),
textView1.bottomAnchor.constraint(equalTo: containerView1.bottomAnchor),
// constrain container Top + 40, Leading / Trailing 80
containerView1.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
containerView1.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 80.0),
containerView1.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -80.0),
// text view 1 will have scrolling enabled, so we'll set its height to 100
containerView1.heightAnchor.constraint(equalToConstant: 100.0),
// constraint text view inside container
textView2.topAnchor.constraint(equalTo: containerView2.topAnchor),
textView2.leadingAnchor.constraint(equalTo: containerView2.leadingAnchor),
textView2.trailingAnchor.constraint(equalTo: containerView2.trailingAnchor),
textView2.bottomAnchor.constraint(equalTo: containerView2.bottomAnchor),
// constrain container2 Top to container1 bottom + 40, Leading / Trailing 80
containerView2.topAnchor.constraint(equalTo: containerView1.bottomAnchor, constant: 40.0),
containerView2.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 80.0),
containerView2.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -80.0),
// text view 2 will NOT scroll (it will size with the text) so no height / bottom
])
// text view 1 should scroll
textView1.isScrollEnabled = true
// text view 1 should NOT scroll we want the text view to size itelf as we type
textView2.isScrollEnabled = false
// let the gradient show through
textView1.backgroundColor = .clear
textView2.backgroundColor = .clear
textView1.text = "Initial text for text view 1."
textView2.text = "Initial text for text view 2."
}
}
I have a view that has a button on top and a textview on bottom as subviews, Im trying to have the button either expand and show the textview or collapse and hide it (like a show/hide)
I used the solution here and it kind of helped, except the subview (text view) was still showing and overlapping with other views so it only hid the main UIView.
here I initialized the height constraint to 25 to leave space for the button:
heightConstraint = detailView.heightAnchor.constraint(equalToConstant: 25)
the action function that'll expand/collapse the view based on the height constraint
#objc func expandViewPressed(sender: UIButton) {
if isAnimating { return }
let shouldCollapse = detailView.frame.height > 25
animateView(isCollapsed: shouldCollapse)
}
the animation function
private func animateView(isCollapsed: Bool) {
heightConstraint[enter image description here][1].isActive = isCollapsed
isAnimating = true
UIView.animate(withDuration: 1, animations: {
self.detailText.isHidden = isCollapsed
self.view.layoutIfNeeded()
}) { (_) in
self.isAnimating = false
}
}
—
expanded view
Sherbini .. please make sure you have added proper constraints to your subviews .. make sure not to add height constraint on any of your subview ..
also make sure you have added view.clipsToBounds == true
Hope it will work for you ..
There are various ways to approach this.
One method is to use two "bottom" constraints:
one from the bottom of the button to the bottom of detailView
one from the bottom of detailText to the bottom of detailView
Then set the constraint Priority of each based on whether the view should be "collapsed" or "expanded."
Here is a full implementation for you to try:
class ExpandViewController: UIViewController {
let myButton: UIButton = {
let v = UIButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.setTitle("Collapse", for: [])
v.backgroundColor = .red
return v
}()
let detailText: UITextView = {
let v = UITextView()
v.translatesAutoresizingMaskIntoConstraints = false
v.text = "This is text in the text view."
return v
}()
let detailView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = UIColor(red: 0.25, green: 0.5, blue: 1.0, alpha: 1.0)
v.clipsToBounds = true
return v
}()
var isAnimating: Bool = false
var collapsedConstraint: NSLayoutConstraint!
var expandedConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 0.0, green: 0.75, blue: 0.0, alpha: 1.0)
detailView.addSubview(myButton)
detailView.addSubview(detailText)
view.addSubview(detailView)
let g = view.safeAreaLayoutGuide
// when collapsed, we want button bottom to constrain detailView bottom
collapsedConstraint = myButton.bottomAnchor.constraint(equalTo: detailView.bottomAnchor, constant: -12.0)
// when expanded, we want textView bottom to constrain detailView bottom
expandedConstraint = detailText.bottomAnchor.constraint(equalTo: detailView.bottomAnchor, constant: -12.0)
// we'll start in Expanded state
expandedConstraint.priority = .defaultHigh
collapsedConstraint.priority = .defaultLow
NSLayoutConstraint.activate([
// constrain detailView Top / Leading / Trailing
detailView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
detailView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
detailView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
// no Height or Bottom constraint for detailView
// constrain button Top / Center / Width
myButton.topAnchor.constraint(equalTo: detailView.topAnchor, constant: 12.0),
myButton.centerXAnchor.constraint(equalTo: detailView.centerXAnchor),
myButton.widthAnchor.constraint(equalToConstant: 200.0),
// constrain detailText Top / Leading / Trailing
detailText.topAnchor.constraint(equalTo: myButton.bottomAnchor, constant: 12.0),
detailText.leadingAnchor.constraint(equalTo: detailView.leadingAnchor, constant: 12.0),
detailText.trailingAnchor.constraint(equalTo: detailView.trailingAnchor, constant: -12.0),
// constrain detailText's Height
detailText.heightAnchor.constraint(equalToConstant: 200.0),
expandedConstraint,
collapsedConstraint,
])
myButton.addTarget(self, action: #selector(self.expandViewPressed(sender:)), for: .touchUpInside)
}
#objc func expandViewPressed(sender: UIButton) {
if isAnimating { return }
animateView()
}
private func animateView() {
isAnimating = true
// if it's expanded
if expandedConstraint.priority == .defaultHigh {
expandedConstraint.priority = .defaultLow
collapsedConstraint.priority = .defaultHigh
} else {
collapsedConstraint.priority = .defaultLow
expandedConstraint.priority = .defaultHigh
detailText.isHidden = false
}
UIView.animate(withDuration: 1, animations: {
self.view.layoutIfNeeded()
}) { (_) in
self.detailText.isHidden = self.expandedConstraint.priority == .defaultLow
self.isAnimating = false
self.myButton.setTitle(self.detailText.isHidden ? "Expand" : "Collapse", for: [])
}
}
}
I am trying to figure out how best to create a view that determines its own height based on the width it is given. The behaviour I desire is very similar to how a vertical UIStackView behaves in that:
When constrained by its top, leading, and trailing edges, it should determine its own natural height based on its content.
When constrained on all edges, it should fill all the available space by expanding and collapsing as determined by those constraints.
In addition, I am looking to achieve this without using autolayout internally to my view.
To illustrate this, please consider the following example:
class ViewController: UIViewController {
let v = View()
override func viewDidLoad() {
super.viewDidLoad()
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
NSLayoutConstraint.activate([
v.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
v.leadingAnchor.constraint(equalTo: view.leadingAnchor),
v.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// Toggle this constraint on and off.
// v.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
}
class View: UIView {
// Imagine this came from computing the size of some child views
// and that it is relative to the width of the bounds.
var contentHeight: CGFloat { bounds.width * 0.5 }
var boundsWidth: CGFloat = 0 {
didSet { if oldValue != boundsWidth { invalidateIntrinsicContentSize() } }
}
override var intrinsicContentSize: CGSize {
.init(width: bounds.width, height: contentHeight)
}
override func layoutSubviews() {
super.layoutSubviews()
boundsWidth = bounds.width
backgroundColor = .red
}
}
This example achieves the behaviour I've described above but I have a big reservation with it because of how I'm using intrinsicContentSize.
The documentation for instrinsicContentSize states that it must be independent of the content frame, which I have not managed. I am calculating the intrinsic size based on the width of the frame.
How is it possible to achieve this behaviour and not make instrinsicContentSize rely on the bounds?
This can all be done with auto-layout constraints. No need to calculate anything.
All you need to do is make sure your custom view's content has appropriate constraints to define the layout.
For example, a UILabel has an intrinsic size based on its text. You can constrain a "top" label to the top of the view, a "middle" label to the bottom of the "top" label, and a "bottom" label to the bottom of the "middle" label and to the bottom of the view.
Here's an example (all via code):
class SizeTestViewController: UIViewController {
let v = ExampleView()
override func viewDidLoad() {
super.viewDidLoad()
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
NSLayoutConstraint.activate([
v.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
v.leadingAnchor.constraint(equalTo: view.leadingAnchor),
v.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
v.topLabel.text = "This is the top label."
v.middleLabel.text = "This is a bunch of text for the middle label. Since we have it set to numberOfLines = 0, the text will wrap onto mutliple lines (assuming it needs to)."
v.bottomLabel.text = "This is the bottom label text\nwith embedded newline characters\nso we can see the multiline feature without needing word wrap."
// so we can see the view's frame
v.backgroundColor = .red
}
}
class ExampleView: UIView {
var topLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .yellow
v.numberOfLines = 0
return v
}()
var middleLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .cyan
v.numberOfLines = 0
return v
}()
var bottomLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .green
v.numberOfLines = 0
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
addSubview(topLabel)
addSubview(middleLabel)
addSubview(bottomLabel)
NSLayoutConstraint.activate([
// constrain topLabel 8-pts from top, leading, trailing
topLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
topLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
topLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
// constrain middleLabel 8-pts from topLabel
// 8-pts from leading, trailing
middleLabel.topAnchor.constraint(equalTo: topLabel.bottomAnchor, constant: 8.0),
middleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
middleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
// constrain bottomLabel 8-pts from middleLabel
// 8-pts from leading, trailing
// 8-pts from bottom
bottomLabel.topAnchor.constraint(equalTo: middleLabel.bottomAnchor, constant: 8.0),
bottomLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
bottomLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
bottomLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
])
}
}
The result:
and rotated, so you can see the auto-sizing:
Edit
A little clarification on Intrinsic Content Size...
In this image, all 5 subviews have an intrinsicContentSize of 120 x 80:
class IntrinsicTestView: UIView {
override var intrinsicContentSize: CGSize {
return CGSize(width: 120, height: 80)
}
}
As you can see:
If I don't add constraints to specify Width - either with a Width constraint or Leading and Trailing constraints - the view will be 120-pts wide.
If I don't add constraints to specify Height - either with a Height constraint or Top and Bottom constraints - the view will be 80-pts tall.
Otherwise, the width and height will be determined by the constraints I've added.
Here's the complete code for that example:
class IntrinsicTestView: UIView {
override var intrinsicContentSize: CGSize {
return CGSize(width: 120, height: 80)
}
}
class IntrinsicExampleViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
var iViews: [IntrinsicTestView] = [IntrinsicTestView]()
var v: IntrinsicTestView
let colors: [UIColor] = [.red, .green, .blue, .yellow, .purple]
colors.forEach { c in
let v = IntrinsicTestView()
v.backgroundColor = c
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
iViews.append(v)
}
let g = view.safeAreaLayoutGuide
// first view at Top: 20 / Leading: 20
v = iViews[0]
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0).isActive = true
// second view at Top: 120 / Leading: 20
// height: 20
v = iViews[1]
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 120.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0).isActive = true
v.heightAnchor.constraint(equalToConstant: 20).isActive = true
// third view at Top: 160 / Leading: 20
// height: 40 / width: 250
v = iViews[2]
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 160.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0).isActive = true
v.heightAnchor.constraint(equalToConstant: 40).isActive = true
v.widthAnchor.constraint(equalToConstant: 250).isActive = true
// fourth view at Top: 220
// trailing: 20 / width: 250
v = iViews[3]
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 220.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0).isActive = true
v.widthAnchor.constraint(equalToConstant: 250).isActive = true
// fourth view at Top: 400 / Leading: 20
// trailing: 20 / bottom: 20
v = iViews[4]
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 400.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0).isActive = true
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0).isActive = true
}
}
As DonMag said in his response, intrinsicContentSize is not quite intended for complex layout management but rather as an indication of a View's preferred size when there are not enough constraints for his frame.
However, there is a simple example i can provide to hopefully point you in the right direction.
Let's assume i want a RatioView to be intrinsically sized so that its height is a linear function of its width:
let ratioView = RatioView()
ratioView.ratio = 0.5
in this example, ratioView will have an intrinsic height equal to half of his width.
The code for RatioView is the following:
class RatioView: UIView {
var contentHeight: CGFloat { bounds.width * ratio }
private var kvoObservation: NSKeyValueObservation?
var ratio: CGFloat = 1 {
didSet { invalidateIntrinsicContentSize() }
}
override var intrinsicContentSize: CGSize {
.init(width: bounds.width, height: contentHeight)
}
override func didMoveToSuperview() {
guard let _ = superview else { return }
kvoObservation = observe(\.frame, options: .initial) { [weak self] (_, change) in
self?.invalidateIntrinsicContentSize()
}
}
override func willMove(toSuperview newSuperview: UIView?) {
if newSuperview == nil {
kvoObservation = nil
}
}
}
There are two important highligths in the above code:
the view calls invalidateIntrinsicContentSize to inform the layout system that the value of his intrinsicContentSize property has changed.
the view is constantly monitoring via KVO observation any changes to his frame property, so that it can calculate its new height every time its width changes.
The second step is especially noteworthy: without it, the view wouldn't know when its size is changing and thus wouldn't have any chance to inform the layout system (via invalidateIntrinsicContentSize) that its intrinsicContentSize has been refreshed.