UIStackView's spacing vanishes when items are removed and re-added - ios

If a UIStackView object contains three or more items, the spacing between the items remains normal when I remove the items from the stack view and then re-add them.
However, if the same UIStackView object contains two items, the spacing between them vanishes when I remove and re-add them.
What is causing this behavior and how can I fix it?
Here is a simple visual:
And here is the simple code associated with my simple visual:
import UIKit
class StackViewTest : UIViewController {
let stackView = UIStackView()
let stackViewLabels = [
//When I use three labels instead of two, everything works normally!
//UILabel(),
UILabel(),
UILabel()
]
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.whiteColor()
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: "reloadStackView"))
//initialize stackView
stackView.spacing = 30
stackView.axis = .Horizontal
stackView.distribution = .EqualSpacing
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)
stackView.centerYAnchor.constraintEqualToAnchor(view.centerYAnchor).active = true
//initialize stackView labels
var labelIndex = 0
for label in stackViewLabels {
label.text = "Label \(labelIndex++)"
}
//initialize tap count label
tapCountLabel.numberOfLines = 3
tapCountLabel.lineBreakMode = .ByWordWrapping
view.addSubview(tapCountLabel)
updateTapCount()
}
//this function is called each time the screen is tapped
func reloadStackView() {
for view in stackView.arrangedSubviews {
stackView.removeArrangedSubview(view)
view.removeFromSuperview()
}
for label in stackViewLabels {
stackView.addArrangedSubview(label)
}
updateTapCount()
}
//for updating the number of screen taps:
let tapCountLabel = UILabel(frame: CGRectMake(0, 30, 400, 90))
var screenTapCount = 0
func updateTapCount() {
tapCountLabel.text = "Tap Count: \(screenTapCount++)\nstackView.spacing: \(stackView.spacing)\n(tap the screen to reload the stackview)"
}
}

Related

UILabel not clickable in stack view programmatically created Swift

My question and code is based on this answer to one of my previous questions. I have programmatically created stackview where several labels are stored and I'm trying to make these labels clickable. I tried two different solutions:
Make clickable label. I created function and assigned it to the label in the gesture recognizer:
public func setTapListener(_ label: UILabel){
let tapGesture: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureMethod(_:)))
tapGesture.numberOfTapsRequired = 1
tapGesture.numberOfTouchesRequired = 1
label.isUserInteractionEnabled = true
label.addGestureRecognizer(tapGesture)
}
#objc func tapGestureMethod(_ gesture: UITapGestureRecognizer) {
print(gesture.view?.tag)
}
but it does not work. Then below the second way....
I thought that maybe the 1st way does not work because the labels are in UIStackView so I decided to assign click listener to the stack view and then determine on which view we clicked. At first I assigned to each of labels in the stackview tag and listened to clicks:
let tap = UITapGestureRecognizer(target: self, action: #selector(didTapCard(sender:)))
labelsStack.addGestureRecognizer(tap)
....
#objc func didTapCard (sender: UITapGestureRecognizer) {
(sender.view as? UIStackView)?.arrangedSubviews.forEach({ label in
print((label as! UILabel).text)
})
}
but the problem is that the click listener works only on the part of the stack view and when I tried to determine on which view we clicked it was not possible.
I think that possibly the problem is with that I tried to assign one click listener to several views, but not sure that works as I thought. I'm trying to make each label in the stackview clickable, but after click I will only need getting text from the label, so that is why I used one click listener for all views.
Applying a transform to a view (button, label, view, etc) changes the visual appearance, not the structure.
Because you're working with rotated views, you need to implement hit-testing.
Quick example:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// convert the point to the labels stack view coordinate space
let pt = labelsStack.convert(point, from: self)
// loop through arranged subviews
for i in 0..<labelsStack.arrangedSubviews.count {
let v = labelsStack.arrangedSubviews[i]
// if converted point is inside subview
if v.frame.contains(pt) {
return v
}
}
return super.hitTest(point, with: event)
}
Assuming you're still working with the MyCustomView class and layout from your previous questions, we'll build on that with a few changes for layout, and to allow tapping the labels.
Complete example:
class Step5VC: UIViewController {
// create the custom "left-side" view
let myView = MyCustomView()
// create the "main" stack view
let mainStackView = UIStackView()
// create the "bottom labels" stack view
let bottomLabelsStack = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
guard let img = UIImage(named: "pro1") else {
fatalError("Need an image!")
}
// create the image view
let imgView = UIImageView()
imgView.contentMode = .scaleToFill
imgView.image = img
mainStackView.axis = .horizontal
bottomLabelsStack.axis = .horizontal
bottomLabelsStack.distribution = .fillEqually
// add views to the main stack view
mainStackView.addArrangedSubview(myView)
mainStackView.addArrangedSubview(imgView)
// add main stack view and bottom labels stack view to view
mainStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mainStackView)
bottomLabelsStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bottomLabelsStack)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain Top/Leading/Trailing
mainStackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
mainStackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
//mainStackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// we want the image view to be 270 x 270
imgView.widthAnchor.constraint(equalToConstant: 270.0),
imgView.heightAnchor.constraint(equalTo: imgView.widthAnchor),
// constrain the bottom lables to the bottom of the main stack view
// same width as the image view
// aligned trailing
bottomLabelsStack.topAnchor.constraint(equalTo: mainStackView.bottomAnchor),
bottomLabelsStack.trailingAnchor.constraint(equalTo: mainStackView.trailingAnchor),
bottomLabelsStack.widthAnchor.constraint(equalTo: imgView.widthAnchor),
])
// setup the left-side custom view
myView.titleText = "Gefährdung"
let titles: [String] = [
"keine / gering", "mittlere", "erhöhte", "hohe",
]
let colors: [UIColor] = [
UIColor(red: 0.863, green: 0.894, blue: 0.527, alpha: 1.0),
UIColor(red: 0.942, green: 0.956, blue: 0.767, alpha: 1.0),
UIColor(red: 0.728, green: 0.828, blue: 0.838, alpha: 1.0),
UIColor(red: 0.499, green: 0.706, blue: 0.739, alpha: 1.0),
]
for (c, t) in zip(colors, titles) {
// because we'll be using hitTest in our Custom View
// we don't need to set .isUserInteractionEnabled = true
// create a "color label"
let cl = colorLabel(withColor: c, title: t, titleColor: .black)
// we're limiting the height to 270, so
// let's use a smaller font for the left-side labels
cl.font = .systemFont(ofSize: 12.0, weight: .light)
// create a tap recognizer
let t = UITapGestureRecognizer(target: self, action: #selector(didTapRotatedLeftLabel(_:)))
// add the recognizer to the label
cl.addGestureRecognizer(t)
// add the label to the custom myView
myView.addLabel(cl)
}
// rotate the left-side custom view 90-degrees counter-clockwise
myView.rotateTo(-.pi * 0.5)
// setup the bottom labels
let colorDictionary = [
"Red":UIColor.systemRed,
"Green":UIColor.systemGreen,
"Blue":UIColor.systemBlue,
]
for (myKey,myValue) in colorDictionary {
// bottom labels are not rotated, so we can add tap gesture recognizer directly
// create a "color label"
let cl = colorLabel(withColor: myValue, title: myKey, titleColor: .white)
// let's use a smaller, bold font for the left-side labels
cl.font = .systemFont(ofSize: 12.0, weight: .bold)
// by default, .isUserInteractionEnabled is False for UILabel
// so we must set .isUserInteractionEnabled = true
cl.isUserInteractionEnabled = true
// create a tap recognizer
let t = UITapGestureRecognizer(target: self, action: #selector(didTapBottomLabel(_:)))
// add the recognizer to the label
cl.addGestureRecognizer(t)
bottomLabelsStack.addArrangedSubview(cl)
}
}
#objc func didTapRotatedLeftLabel (_ sender: UITapGestureRecognizer) {
if let v = sender.view as? UILabel {
let title = v.text ?? "label with no text"
print("Tapped Label in Rotated Custom View:", title)
// do something based on the tapped label/view
}
}
#objc func didTapBottomLabel (_ sender: UITapGestureRecognizer) {
if let v = sender.view as? UILabel {
let title = v.text ?? "label with no text"
print("Tapped Bottom Label:", title)
// do something based on the tapped label/view
}
}
func colorLabel(withColor color:UIColor, title:String, titleColor:UIColor) -> UILabel {
let newLabel = PaddedLabel()
newLabel.padding = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8)
newLabel.backgroundColor = color
newLabel.text = title
newLabel.textAlignment = .center
newLabel.textColor = titleColor
newLabel.setContentHuggingPriority(.required, for: .vertical)
return newLabel
}
}
class MyCustomView: UIView {
public var titleText: String = "" {
didSet { titleLabel.text = titleText }
}
public func addLabel(_ v: UIView) {
labelsStack.addArrangedSubview(v)
}
public func rotateTo(_ d: Double) {
// get the container view (in this case, it's the outer stack view)
if let v = subviews.first {
// set the rotation transform
if d == 0 {
self.transform = .identity
} else {
self.transform = CGAffineTransform(rotationAngle: d)
}
// remove the container view
v.removeFromSuperview()
// tell it to layout itself
v.setNeedsLayout()
v.layoutIfNeeded()
// get the frame of the container view
// apply the same transform as self
let r = v.frame.applying(self.transform)
wC.isActive = false
hC.isActive = false
// add it back
addSubview(v)
// set self's width and height anchors
// to the width and height of the container
wC = self.widthAnchor.constraint(equalToConstant: r.width)
hC = self.heightAnchor.constraint(equalToConstant: r.height)
guard let sv = v.superview else {
fatalError("no superview")
}
// apply the new constraints
NSLayoutConstraint.activate([
v.centerXAnchor.constraint(equalTo: self.centerXAnchor),
v.centerYAnchor.constraint(equalTo: self.centerYAnchor),
wC,
outerStack.widthAnchor.constraint(equalTo: sv.heightAnchor),
])
}
}
// our subviews
private let outerStack = UIStackView()
private let titleLabel = UILabel()
private let labelsStack = UIStackView()
private var wC: NSLayoutConstraint!
private var hC: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// stack views and label properties
outerStack.axis = .vertical
outerStack.distribution = .fillEqually
labelsStack.axis = .horizontal
// let's use .fillProportionally to help fit the labels
labelsStack.distribution = .fillProportionally
titleLabel.textAlignment = .center
titleLabel.backgroundColor = .lightGray
titleLabel.textColor = .white
// add title label and labels stack to outer stack
outerStack.addArrangedSubview(titleLabel)
outerStack.addArrangedSubview(labelsStack)
outerStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(outerStack)
wC = self.widthAnchor.constraint(equalTo: outerStack.widthAnchor)
hC = self.heightAnchor.constraint(equalTo: outerStack.heightAnchor)
NSLayoutConstraint.activate([
outerStack.centerXAnchor.constraint(equalTo: self.centerXAnchor),
outerStack.centerYAnchor.constraint(equalTo: self.centerYAnchor),
wC, hC,
])
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// convert the point to the labels stack view coordinate space
let pt = labelsStack.convert(point, from: self)
// loop through arranged subviews
for i in 0..<labelsStack.arrangedSubviews.count {
let v = labelsStack.arrangedSubviews[i]
// if converted point is inside subview
if v.frame.contains(pt) {
return v
}
}
return super.hitTest(point, with: event)
}
}
class PaddedLabel: UILabel {
var padding: UIEdgeInsets = .zero
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: padding))
}
override var intrinsicContentSize : CGSize {
let sz = super.intrinsicContentSize
return CGSize(width: sz.width + padding.left + padding.right, height: sz.height + padding.top + padding.bottom)
}
}
The problem is with the the stackView's height. Once the label is rotated, the stackview's height is same as before and the tap gestures will only work within stackview's bounds.
I have checked it by changing the height of the stackview at the transform and observed tap gestures are working fine with the rotated label but with the part of it inside the stackview.
Now the problem is that you have to keep the bounds of the label inside the stackview either by changing it axis(again a new problem as need to handle the layout with it) or you have to handle it without the stackview.
You can check the observation by clicking the part of rotated label inside stackview and outside stackview.
Code to check it:
class ViewController: UIViewController {
var centerLabel = UILabel()
let mainStackView = UIStackView()
var stackViewHeightCons:NSLayoutConstraint?
var stackViewTopsCons:NSLayoutConstraint?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
mainStackView.axis = .horizontal
mainStackView.alignment = .top
mainStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mainStackView)
mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
stackViewTopsCons = mainStackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 300)
stackViewTopsCons?.isActive = true
stackViewHeightCons = mainStackView.heightAnchor.constraint(equalToConstant: 30)
stackViewHeightCons?.isActive = true
centerLabel.textAlignment = .center
centerLabel.text = "Let's rotate this label"
centerLabel.backgroundColor = .green
centerLabel.tag = 11
setTapListener(centerLabel)
mainStackView.addArrangedSubview(centerLabel)
// outline the stack view so we can see its frame
mainStackView.layer.borderColor = UIColor.red.cgColor
mainStackView.layer.borderWidth = 1
}
public func setTapListener(_ label: UILabel){
let tapGesture: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureMethod(_:)))
tapGesture.numberOfTapsRequired = 1
tapGesture.numberOfTouchesRequired = 1
label.isUserInteractionEnabled = true
label.addGestureRecognizer(tapGesture)
}
#objc func tapGestureMethod(_ gesture: UITapGestureRecognizer) {
print(gesture.view?.tag ?? 0)
var yCor:CGFloat = 300
if centerLabel.transform == .identity {
centerLabel.transform = CGAffineTransform(rotationAngle: -CGFloat.pi / 2)
yCor = mainStackView.frame.origin.y - (centerLabel.frame.size.height/2)
} else {
centerLabel.transform = .identity
}
updateStackViewHeight(topCons: yCor)
}
private func updateStackViewHeight(topCons:CGFloat) {
stackViewTopsCons?.constant = topCons
stackViewHeightCons?.constant = centerLabel.frame.size.height
}
}
Sorry. My assumption was incorrect.
Why are you decided to use Label instead of UIButton (with transparence background color and border line)?
Also you can use UITableView instead of stack & labels
Maybe this documentation will help too (it is written that usually in one view better to keep one gesture recognizer): https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/coordinating_multiple_gesture_recognizers

UIStackView, arrange subviews only while they fit

I have an array of UIButtons that I am trying to add to an horizontal UIStackView. The number of these buttons is dynamic and they can become that high to not fit on the screen.
For my use case I would like to add the buttons, from the left of my stack view, while they fit on the screen. So if I have 10 buttons, and after adding the 5th one there would not be enough space to add the 6th button, I would like to stop at that moment.
Does anybody knows a good approach to get this behaviour?
My current code looks similar to this:
import UIKit
class ViewController: UIViewController {
let items = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven"]
override func viewDidLoad() {
super.viewDidLoad()
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 5
view.addSubview(stackView)
for title in items {
let button = UIButton()
button.backgroundColor = .red
button.setTitle(title, for: .normal)
// if button.doesNotFit() {
// break
// }
stackView.addArrangedSubview(button)
}
let spacerView = UIView()
spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal)
stackView.addArrangedSubview(spacerView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
stackView.heightAnchor.constraint(equalToConstant: 50).isActive = true
}
}
With that, i get the following result. The problem is in the button "Nine", "Ten", etc... because they do not fit. I would not like them to be added to the UIStackView.
What you'll need to do...
in viewDidLoad()
create the buttons and add them to an array
in viewDidLayoutSubviews() (so we have a valid width of the view)
get the available width
clear any existing buttons from the stack view
loop through the buttons array, adding the width of the button + spacing
if the width is less than available width, add the button to the stack view
Here is a full example. I included .left, .right and .justified options:
// ButtonsAlignment is
// left = buttons will be left-aligned with fixed spacing
// right = buttons will be right-aligned with fixed spacing
// justified = buttons will be justified edge to edge with equal spacing
enum ButtonsAlignment {
case left
case right
case justified
}
class StackSpacingViewController: UIViewController {
let items = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven"]
// array to hold your buttons
var buttonsArray: [UIButton] = []
// change to .left, .right, .justified to see the difference
let spacingMode: ButtonsAlignment = .left
// if mode is .justified, this will be the minimum spacing
let buttonSpacing: CGFloat = 5.0
// need to track when the view width changes (when auto-layout has set it)
// for use in viewDidLayoutSubviews()
var viewWidth: CGFloat = 0.0
// we'll need to refrence the stack view in multiple places,
// so don't make it local to viewDidLoad()
let stackView = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
stackView.axis = .horizontal
if spacingMode == .left || spacingMode == .right {
stackView.distribution = .fill
stackView.spacing = buttonSpacing
} else {
stackView.distribution = .equalSpacing
}
view.addSubview(stackView)
for title in items {
let button = UIButton()
button.backgroundColor = .red
button.setTitle(title, for: .normal)
// append it to the buttonsArray
buttonsArray.append(button)
}
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
stackView.heightAnchor.constraint(equalToConstant: 50).isActive = true
if spacingMode == .left || spacingMode == .justified {
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
}
if spacingMode == .right || spacingMode == .justified {
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// this can (likely will) be called multiple times
// so only update the buttons stack view when the
// view's width changes
if viewWidth != view.frame.width {
let w = view.frame.width
viewWidth = w
// clear any buttons already in the stack
stackView.subviews.forEach {
$0.removeFromSuperview()
}
var curWidth: CGFloat = 0.0
for btn in buttonsArray {
curWidth += btn.intrinsicContentSize.width + buttonSpacing
if curWidth < w {
stackView.addArrangedSubview(btn)
}
}
stackView.setNeedsLayout()
stackView.layoutIfNeeded()
}
}
}
Note: this could (probably would) work better as a self-contained custom view class... would make it easy to add a "background" color, and the calculations could be handled in the subclass's layoutSubviews() function.

Embedd StackView in ScrollView that is embedded in a main StackView

Embedd StackView in ScrollView that is embedded in a main StackView
I am having trouble with a rather complicated detail view that I want to do programmatically. My view hierarchy looks something like this:
Since this might be better explained visualising, I have a screenshot here:
My problem is that I don't know how to set the height constraint on descriptionTextView – right now it's set to 400. What I want though is that it takes up all the space available as the middle item of the main stack view. Once one or more comments are added to the contentStackView, the text field should shrink.
I am not sure which constraints for which views I must set to achieve this...
Here's my take on it so far:
import UIKit
class DetailSampleViewController: UIViewController {
lazy var mainStackView: UIStackView = {
let m = UIStackView()
m.axis = .vertical
m.alignment = .fill
m.distribution = .fill
m.spacing = 10
m.translatesAutoresizingMaskIntoConstraints = false
m.addArrangedSubview(titleTextField)
m.addArrangedSubview(contentScrollView)
m.addArrangedSubview(footerStackView)
return m
}()
lazy var titleTextField: UITextField = {
let t = UITextField()
t.borderStyle = .roundedRect
t.placeholder = "Some Fancy Placeholder"
t.text = "Some Fancy Title"
t.translatesAutoresizingMaskIntoConstraints = false
return t
}()
lazy var contentScrollView: UIScrollView = {
let s = UIScrollView()
s.contentMode = .scaleToFill
s.keyboardDismissMode = .onDrag
s.translatesAutoresizingMaskIntoConstraints = false
s.addSubview(contentStackView)
return s
}()
lazy var contentStackView: UIStackView = {
let s = UIStackView()
s.translatesAutoresizingMaskIntoConstraints = false
s.axis = .vertical
s.alignment = .fill
s.distribution = .equalSpacing
s.spacing = 10
s.contentMode = .scaleToFill
s.addArrangedSubview(descriptionTextView)
s.addArrangedSubview(getCommentLabel(with: "Some fancy comment"))
s.addArrangedSubview(getCommentLabel(with: "Another fancy comment"))
s.addArrangedSubview(getCommentLabel(with: "And..."))
s.addArrangedSubview(getCommentLabel(with: "..even..."))
s.addArrangedSubview(getCommentLabel(with: "...more..."))
s.addArrangedSubview(getCommentLabel(with: "...comments..."))
s.addArrangedSubview(getCommentLabel(with: "Some fancy comment"))
s.addArrangedSubview(getCommentLabel(with: "Another fancy comment"))
s.addArrangedSubview(getCommentLabel(with: "And..."))
s.addArrangedSubview(getCommentLabel(with: "..even..."))
s.addArrangedSubview(getCommentLabel(with: "...more..."))
s.addArrangedSubview(getCommentLabel(with: "...comments..."))
return s
}()
lazy var descriptionTextView: UITextView = {
let tv = UITextView()
tv.font = UIFont.systemFont(ofSize: 17.0)
tv.clipsToBounds = true
tv.layer.cornerRadius = 5.0
tv.layer.borderWidth = 0.25
tv.translatesAutoresizingMaskIntoConstraints = false
tv.text = """
Some fancy textfield text,
spanning over multiple
lines
...
"""
return tv
}()
lazy var footerStackView: UIStackView = {
let f = UIStackView()
f.axis = .horizontal
f.alignment = .fill
f.distribution = .fillEqually
let commentLabel = UILabel()
commentLabel.text = "Comments"
let addCommentButton = UIButton(type: UIButton.ButtonType.system)
addCommentButton.setTitle("Add Comment", for: .normal)
f.addArrangedSubview(commentLabel)
f.addArrangedSubview(addCommentButton)
return f
}()
override func loadView() {
view = UIView()
view.backgroundColor = . systemBackground
navigationController?.isToolbarHidden = true
view.addSubview(mainStackView)
NSLayoutConstraint.activate([
mainStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
mainStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
mainStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
mainStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
titleTextField.heightAnchor.constraint(equalToConstant: titleTextField.intrinsicContentSize.height),
contentStackView.leadingAnchor.constraint(equalTo: contentScrollView.leadingAnchor),
contentStackView.trailingAnchor.constraint(equalTo: contentScrollView.trailingAnchor),
contentStackView.topAnchor.constraint(equalTo: contentScrollView.topAnchor),
contentStackView.bottomAnchor.constraint(equalTo: contentScrollView.bottomAnchor),
descriptionTextView.heightAnchor.constraint(equalToConstant: 400),
descriptionTextView.leadingAnchor.constraint(equalTo: mainStackView.leadingAnchor),
descriptionTextView.trailingAnchor.constraint(equalTo: mainStackView.trailingAnchor),
])
}
override func viewDidLoad() {
super.viewDidLoad()
title = "Detail View"
}
func getCommentLabel(with text: String) -> UILabel {
let l = UILabel()
l.layer.borderWidth = 0.25
l.translatesAutoresizingMaskIntoConstraints = false
l.text = text
return l
}
}
You're close, but a couple notes:
When using stack views - particularly inside scroll views - you sometimes need to explicitly define which elements can be stretched or not, and which elements can be compressed or not.
To get the scroll view filled before it has enough content, you need to set constraints so the combined content height is equal to the scroll view frame's height, but give that constraint a low priority so auto-layout can "break" it when you have enough vertical content.
A personal preference: I'm generally not a fan of adding subviews inside lazy var declarations. It can become confusing when trying to setup constraints.
I've re-worked your posted code to at least get close to what you're going for. It starts with NO comment labels... tapping the "Add Comment" button will add "numbered comment labels" and every third comment will wrap onto multiple lines.
Not really all that much in the way of changes... and I think I added enough comments to make things clear.
class DetailSampleViewController: UIViewController {
lazy var mainStackView: UIStackView = {
let m = UIStackView()
m.axis = .vertical
m.alignment = .fill
m.distribution = .fill
m.spacing = 10
m.translatesAutoresizingMaskIntoConstraints = false
// don't add subviews here
return m
}()
lazy var titleTextField: UITextField = {
let t = UITextField()
t.borderStyle = .roundedRect
t.placeholder = "Some Fancy Placeholder"
t.text = "Some Fancy Title"
t.translatesAutoresizingMaskIntoConstraints = false
return t
}()
lazy var contentScrollView: UIScrollView = {
let s = UIScrollView()
s.contentMode = .scaleToFill
s.keyboardDismissMode = .onDrag
s.translatesAutoresizingMaskIntoConstraints = false
// don't add subviews here
return s
}()
lazy var contentStackView: UIStackView = {
let s = UIStackView()
s.translatesAutoresizingMaskIntoConstraints = false
s.axis = .vertical
s.alignment = .fill
// distribution needs to be .fill (not .equalSpacing)
s.distribution = .fill
s.spacing = 10
s.contentMode = .scaleToFill
// don't add subviews here
return s
}()
lazy var descriptionTextView: UITextView = {
let tv = UITextView()
tv.font = UIFont.systemFont(ofSize: 17.0)
tv.clipsToBounds = true
tv.layer.cornerRadius = 5.0
tv.layer.borderWidth = 0.25
tv.translatesAutoresizingMaskIntoConstraints = false
tv.text = """
Some fancy textfield text,
spanning over multiple lines.
This textView now has a minimum height of 160-pts.
"""
return tv
}()
lazy var footerStackView: UIStackView = {
let f = UIStackView()
f.axis = .horizontal
f.alignment = .fill
f.distribution = .fillEqually
let commentLabel = UILabel()
commentLabel.text = "Comments"
let addCommentButton = UIButton(type: UIButton.ButtonType.system)
addCommentButton.setTitle("Add Comment", for: .normal)
// add a target so we can add comment labels
addCommentButton.addTarget(self, action: #selector(addCommentLabel(_:)), for: .touchUpInside)
// don't allow button height to be compressed
addCommentButton.setContentCompressionResistancePriority(.required, for: .vertical)
f.addArrangedSubview(commentLabel)
f.addArrangedSubview(addCommentButton)
return f
}()
// just for demo - numbers the added comment labels
var commentIndex: Int = 0
// do all this in viewDidLoad(), not in loadView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = . systemBackground
navigationController?.isToolbarHidden = true
title = "Detail View"
// add the mainStackView
view.addSubview(mainStackView)
// add elements to mainStackView
mainStackView.addArrangedSubview(titleTextField)
mainStackView.addArrangedSubview(contentScrollView)
mainStackView.addArrangedSubview(footerStackView)
// add contentStackView to contentScrollView
contentScrollView.addSubview(contentStackView)
// add descriptionTextView to contentStackView
contentStackView.addArrangedSubview(descriptionTextView)
// tell contentStackView to be the height of contentScrollView frame
let contentStackHeight = contentStackView.heightAnchor.constraint(equalTo: contentScrollView.frameLayoutGuide.heightAnchor)
// but give it a lower priority do it can grow as comment labels are added
contentStackHeight.priority = .defaultLow
NSLayoutConstraint.activate([
// constrain mainStackView top / bottom / leading / trailing to safe area
mainStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
mainStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
mainStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
mainStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
// title text field
titleTextField.heightAnchor.constraint(equalToConstant: titleTextField.intrinsicContentSize.height),
// minimum height for descriptionTextView
descriptionTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 160.0),
// constrain contentStackView top / leading / trailing / bottom to contentScrollView
contentStackView.topAnchor.constraint(equalTo: contentScrollView.topAnchor),
contentStackView.leadingAnchor.constraint(equalTo: contentScrollView.leadingAnchor),
contentStackView.trailingAnchor.constraint(equalTo: contentScrollView.trailingAnchor),
contentStackView.bottomAnchor.constraint(equalTo: contentScrollView.bottomAnchor),
// constrain contentStackView width to contentScrollView frame
contentStackView.widthAnchor.constraint(equalTo: contentScrollView.frameLayoutGuide.widthAnchor),
// activate contentStackHeight constraint
contentStackHeight,
])
// during dev, give some background colors so we can see the frames
contentScrollView.backgroundColor = .cyan
descriptionTextView.backgroundColor = .yellow
}
#objc func addCommentLabel(_ sender: Any?) -> Void {
// commentIndex is just used to number the added comments
commentIndex += 1
// let's make every third label end up with multiple lines, just to
// confirm variable-height labels won't mess things up
var s = "This is label \(commentIndex)"
if commentIndex % 3 == 0 {
s += ", and it has enough text that it should need to wrap onto multiple lines, even in landscape orientation."
}
let v = getCommentLabel(with: s)
// don't let comment labels stretch vertically
v.setContentHuggingPriority(.required, for: .vertical)
// don't let comment labels get compressed vertically
v.setContentCompressionResistancePriority(.required, for: .vertical)
contentStackView.addArrangedSubview(v)
// auto-scroll to bottom to show newly added comment label
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
let r = CGRect(x: 0.0, y: self.contentScrollView.contentSize.height - 1.0, width: 1.0, height: 1.0)
self.contentScrollView.scrollRectToVisible(r, animated: true)
}
}
func getCommentLabel(with text: String) -> UILabel {
let l = UILabel()
l.layer.borderWidth = 0.25
l.translatesAutoresizingMaskIntoConstraints = false
l.text = text
// allow wrapping / multi-line comments
l.numberOfLines = 0
return l
}
}

have a vertical stackview take all the spacing inside another stackview

I am trying to achieve the following layout in a table cell (it has a dynamic height):
The result I am getting is that 2nd stackview doesn't stretch to add more height to fill the multiline label.
lazy var stackView: StackView = {
let subviews: [UIView] = []
let view = StackView(arrangedSubviews: subviews)
view.axis = .horizontal
view.alignment = .center
self.containerView.addSubview(view)
view.snp.makeConstraints({ (make) in
make.edges.equalToSuperview().inset(inset)
})
return view
}()
lazy var detailsStackView: StackView = {
let subviews: [UIView] = []
let view = StackView(arrangedSubviews: subviews)
view.axis = .vertical
stackView.alignment = .fill
return view
}()
lazy var nameLabel: Label = {
let view = Label(style: .styleBoldSize24)
return view
}()
let detailsLabel: Label = {
let label = Label(style: .styleRegular25)
label.textAlignment = .left
label.numberOfLines = 10
label.text = "Where does it come from Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of anything embarrassing hidden in the mid words etc."
return label
}()
lazy var avatarImageView: ImageView = {
let view = ImageView(frame: CGRect())
view.snp.makeConstraints({ (make) in
make.width.height.equalTo(100)
})
return view
}()
override func setup() {
super.setup()
stackView.axis = .horizontal
stackView.alignment = .top
stackView.addArrangedSubview(avatarImageView)
detailsStackView.spacing = self.inset
detailsStackView.addArrangedSubview(nameLabel)
detailsStackView.addArrangedSubview(detailsLabel)
stackView.addArrangedSubview(avatarImageView)
stackView.addArrangedSubview(detailsStackView)
stackView.snp.remakeConstraints({ (make) in
let inset = self.inset
make.edges.equalToSuperview().inset(UIEdgeInsets(top: inset/2, left: inset, bottom: inset/2, right: inset))
})}

UIStackView doesn't fill if removing and adding the same views

I found an issue in iOS 9 where the layout is not working properly when removing and adding the same views to UIStackView and uses intrinsic content size of the views instead of the specified fillEqually distribution type.
For example, this code:
class ViewController: UIViewController {
let stackView = UIStackView()
let red = UIButton()
let blue = UIButton()
let green = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
stackView.distribution = .fillEqually
red.backgroundColor = .red
blue.backgroundColor = .blue
green.backgroundColor = .green
let views = [red, blue, green]
for view in views {
stackView.addArrangedSubview(view)
}
view.backgroundColor = .lightGray
view.addSubview(stackView)
stackView.frame = view.bounds.insetBy(dx: 0, dy: view.frame.height / 3)
let button = UIButton()
button.frame = view.bounds
view.addSubview(button)
button.addTarget(self, action: #selector(buttonClicked), for: .touchUpInside)
}
func buttonClicked() {
for view in stackView.arrangedSubviews {
view.removeFromSuperview()
}
stackView.addArrangedSubview(red)
stackView.addArrangedSubview(blue)
}
}
Shows the following result after tapping the screen:
But on iOS 10 it shows the expected result:
Creating new views instead of reusing the old ones outputs the expected layout on iOS 9:
func buttonClicked() {
for view in stackView.arrangedSubviews {
view.removeFromSuperview()
}
let red = UIButton()
let blue = UIButton()
red.backgroundColor = .red
blue.backgroundColor = .blue
stackView.addArrangedSubview(red)
stackView.addArrangedSubview(blue)
}
But the whole point of this code is reusing views to improve performance.
Is this an issue in my code? A bug on iOS 9? Is there any known workaround?
for view in stackView.arrangedSubviews {
stackView.removeArrangedSubview(view)
view.removeFromSuperview()
}
I think you forget to call removeArrangedSubview.
Solved it by explicitly calling layoutIfNeeded method after removing all the views:
func buttonClicked() {
for view in stackView.arrangedSubviews {
view.removeFromSuperview()
}
stackView.layoutIfNeeded() // Required to avoid layout issues in iOS 9
stackView.addArrangedSubview(red)
stackView.addArrangedSubview(blue)
}

Resources