UIStackView setCustomSpacing at runtime - swift

I have a horizontal UIStackView that, by default, looks as follows:
The view with the heart is initially hidden and then shown at runtime. I would like to reduce the spacing between the heart view and the account name view.
The following code does the job, but only, when executed in viewDidLoad:
stackView.setCustomSpacing(8, after: heartView)
When changing the custom spacing later on, say on a button press, it doesn't have any effect. Now, the issue here is, that the custom spacing is lost, once the subviews inside the stack view change: when un-/hiding views from the stack view, the custom spacing is reset and cannot be modified.
Things, I've tried:
verified the spacing is set by printing stackView.customSpacing(after: heartView) (which properly returns 8)
unsuccessfully ran several reload functions:
stackView.layoutIfNeeded()
stackView.layoutSubviews()
view.layoutIfNeeded()
view.layoutSubviews()
viewDidLayoutSubviews()
How can I update the custom spacing of my stack view at runtime?

You need to make sure the UIStackView's distribution property is set to .fill or .fillProportionally.
I created the following swift playground and it looks like I am able to use setCustomSpacing at runtime with random values and see the effect of that.
import UIKit
import PlaygroundSupport
public class VC: UIViewController {
let view1 = UIView()
let view2 = UIView()
let view3 = UIView()
var stackView: UIStackView!
public init() {
super.init(nibName: nil, bundle: nil)
}
public required init?(coder aDecoder: NSCoder) {
fatalError()
}
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view1.backgroundColor = .red
view2.backgroundColor = .green
view3.backgroundColor = .blue
view2.isHidden = true
stackView = UIStackView(arrangedSubviews: [view1, view2, view3])
stackView.spacing = 10
stackView.axis = .horizontal
stackView.distribution = .fillProportionally
let uiSwitch = UISwitch()
uiSwitch.addTarget(self, action: #selector(onSwitch), for: .valueChanged)
view1.addSubview(uiSwitch)
uiSwitch.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
uiSwitch.centerXAnchor.constraint(equalTo: view1.centerXAnchor),
uiSwitch.centerYAnchor.constraint(equalTo: view1.centerYAnchor)
])
view.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.heightAnchor.constraint(equalToConstant: 50),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50)
])
}
#objc public func onSwitch(sender: Any) {
view2.isHidden = !view2.isHidden
if !view2.isHidden {
stackView.setCustomSpacing(CGFloat(arc4random_uniform(40)), after: view2)
}
}
}
PlaygroundPage.current.liveView = VC()
PlaygroundPage.current.needsIndefiniteExecution = true

Another reason setCustomSpacing can fail is if you call it before adding the arranged subview after which you want to apply the spacing.
Won't work:
headerStackView.setCustomSpacing(50, after: myLabel)
headerStackView.addArrangedSubview(myLabel)
Will work:
headerStackView.addArrangedSubview(myLabel)
headerStackView.setCustomSpacing(50, after: myLabel)

I also noticed that custom spacing values get reset after hiding/unhiding children. I was able to override updateConstraints() for my parent view and set the custom spacing as needed. The views then kept their intended spacing.
override func updateConstraints() {
super.updateConstraints()
stackView.setCustomSpacing(10, after: childView)
}

Related

How to make Vertical StackView subviews (same specific heights) start from TOP, Swift?

I'm creating a vertical UIStackView where arranged subviews will start from top. Subviews quantity will be 5 at most. Here are my expectation, reality and code. Any Idea?
Expectation
Current situation
Code
var homeVStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .top
stackView.distribution = .equalSpacing
stackView.spacing = 20
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
private func loadData() {
if let homeFormList = data?.homeTeamForm {
for homeForm in homeFormList {
let teamFormView = SimpleScoreView()
teamFormView.teamForm = homeForm
teamFormView.heightAnchor.constraint(equalToConstant: 50).isActive = true
teamFormView.backgroundColor = .yellow
homeVStack.addArrangedSubview(teamFormView)
}
}
}
Use empty view like UIView() on your last arranged view. It should be made larger than its intrinsic size.
var homeVStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .fill // default is .fill
stackView.distribution = .fill
stackView.spacing = 20
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
private func loadData() {
if let homeFormList = data?.homeTeamForm {
for homeForm in homeFormList {
let teamFormView = SimpleScoreView()
teamFormView.teamForm = homeForm
teamFormView.heightAnchor.constraint(equalToConstant: 50).isActive = true
teamFormView.backgroundColor = .yellow
homeVStack.addArrangedSubview(teamFormView)
}
}
homeVStack.addArrangedSubview(UIView()) // Important
}
Above code make your stack view layout like this. UIView should be stretched than SimpleScoreView's intrinsic size
Current Situation image said homeVStacks constraints are top, bot, leading and trailing to its superview with equal spacing so second SimpleScoreView is on bottom.
You can choice two options
You should make spacing with another UI like transparent UIView for stretched instead of SimpleScoreView bottom layout.
Remove stack view's bottom constraint to root view and make height by contents(arranged view)
You are explicitly setting the Height of the subviews:
teamFormView.heightAnchor.constraint(equalToConstant: 50).isActive = true
So, do NOT give your stackView a Bottom anchor or Height constraint.
Here's a quick example...
SimpleScoreView -- view with a centered label:
class SimpleScoreView: UIView {
let scoreLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
self.backgroundColor = .yellow
scoreLabel.textAlignment = .center
scoreLabel.translatesAutoresizingMaskIntoConstraints = false
scoreLabel.backgroundColor = .green
addSubview(scoreLabel)
NSLayoutConstraint.activate([
scoreLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
scoreLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
}
Basic View Controller -- each tap will add another "Score View":
class ViewController: UIViewController {
var homeVStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
// these two are not needed
//stackView.alignment = .top
//stackView.distribution = .equalSpacing
stackView.spacing = 20
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(homeVStack)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain stack view Top / Leading / Trailing
homeVStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 8.0),
homeVStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
homeVStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
])
loadData()
}
// for this example, loadData() will add one new
// SimpleScoreView each time it's called
private func loadData() {
let v = SimpleScoreView()
v.heightAnchor.constraint(equalToConstant: 50).isActive = true
v.scoreLabel.text = "\(homeVStack.arrangedSubviews.count + 1)"
homeVStack.addArrangedSubview(v)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
loadData()
}
}
Results:
what are the constraints of homeVStack? make it center horizontally and vertically in super View.

Stretchy Header with UIPageViewController

My problem seems obvious and duplicated but I can't manage to make it work.
I'm trying to achieve the famous stretchy header effect (image's top side stuck to top of UIScrollView when scrolling), but with an UIPageViewController instead of simply an image.
My structure is:
UINavigationBar
|-- UIScrollView
|-- UIView (totally optional container)
|-- UIPageViewController (as UIView, embedded with addChild()) <-- TO STICK
|-- UIHostingViewController (SwiftUI view with labels, also embedded)
|-- UITableView (not embedded but could be)
My UIPageViewController contains images to make a carousel, nothing more.
All my views are laid out with NSLayoutConstraints (with visual format for vertical layout in the container).
I trie sticking topAnchor of the page controller's view to the one of self.view (with or without priority) but no luck, and no matter what I do it changes absolutely nothing.
I finally tried to use SnapKit but it doesn't work neither (I don't know much about it but it seems to only be a wrapper for NSLayoutConstaints so I'm not surprised it doesn't work too).
I followed this tutorial, this one and that one but none of them worked.
(How) can I achieve what I want?
EDIT 1:
To clarify, my carousel currently has a forced height of 350. I want to achieve this exact effect (that is shown with a single UIImageView) on my whole carousel:
To clarify as much as possible, I want to replicate this effect to my whole UIPageViewController/carousel so that the displayed page/image can have this effect when scrolled.
NOTE: as mentioned in the structure above, I have a (transparent) navigation bar, and my safe area insets are respected (nothing goes under the status bar). I don't think it would change the solution (as the solution is probably a way to stick the top of the carousel to self.view, no matter the frame of self.view) but I prefer you to know everything.
EDIT 2:
Main VC with #DonMag's answer:
private let info: UITableView = {
let v = UITableView(frame: .zero, style: .insetGrouped)
v.backgroundColor = .systemBackground
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
private lazy var infoHeightConstraint: NSLayoutConstraint = {
// Needed constraint because else standalone UITableView gets an height of 0 even with usual constraints
// I update this constraint in viewWillAppear & viewDidAppear when the table gets a proper contentSize
info.heightAnchor.constraint(equalToConstant: 0.0)
}()
private let scrollView: UIScrollView = {
let v = UIScrollView()
v.contentInsetAdjustmentBehavior = .never
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
...
// MARK: Views declaration
// Container for carousel
let stretchyView = UIView()
stretchyView.translatesAutoresizingMaskIntoConstraints = false
// Carousel
let carouselController = ProfileDetailCarousel(images: [
UIImage(named: "1")!,
UIImage(named: "2")!,
UIImage(named: "3")!,
UIImage(named: "4")!
])
addChild(carouselController)
let carousel: UIView = carouselController.view
carousel.translatesAutoresizingMaskIntoConstraints = false
stretchyView.addSubview(carousel)
carouselController.didMove(toParent: self)
// Container for below-carousel views
let contentView = UIView()
contentView.translatesAutoresizingMaskIntoConstraints = false
// Texts and bio
let bioController = UIHostingController(rootView: ProfileDetailBio())
addChild(bioController)
let bio: UIView = bioController.view
bio.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(bio)
bioController.didMove(toParent: self)
// Info table
info.delegate = tableDelegate
info.dataSource = tableDataSource
tableDelegate.viewController = self
contentView.addSubview(info)
[stretchyView, contentView].forEach { v in
scrollView.addSubview(v)
}
view.addSubview(scrollView)
// MARK: Constraints
let stretchyTop = stretchyView.topAnchor.constraint(equalTo: scrollView.frameLayoutGuide.topAnchor)
stretchyTop.priority = .defaultHigh
NSLayoutConstraint.activate([
// Scroll view
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
// Stretchy view
stretchyTop,
stretchyView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor),
stretchyView.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 350.0),
// Carousel
carousel.topAnchor.constraint(equalTo: stretchyView.topAnchor),
carousel.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
carousel.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
carousel.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
// Content view
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 350.0),
contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor),
// Bio
bio.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10.0),
bio.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Info table
info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
info.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
info.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
infoHeightConstraint
])
}
Your view hierarchy should be:
UINavigationBar
|-- UIScrollView
|-- UIView ("stretchy" container view)
|-- UIPageViewController (as UIView, embedded with asChild())
|-- UIHostingViewController (SwiftUI view with labels, also embedded)
To get the stretchy view to "stick to the top":
We constrain the stretchy view's Top to the scroll view's .frameLayoutGuide Top, but we give that constraint a less-than-required .priority so we can "push it" up and off the screen.
We also give the stretchy view a Height constraint of greater-than-or-equal-to 350. This will allow it to stretch - but not compress - vertically.
We'll call the view from the UIHostingViewController our "contentView" ... and we'll constrain its Top to the stretchy view's Bottom.
Then, we give the content view another Top constraint -- this time to the scroll view's .contentLayoutGuide, with a constant of 350 (the height of the stretchy view). This, plus the Leading/Trailing/Bottom constraints defines the "scrollable area."
When we scroll (pull) down, the content view will "pull down" the Bottom of the stretchy view.
When we scroll (push) up, the content view will "push up" the entire stretchy view.
Here's how it looks (too big to add as a gif here): https://imgur.com/a/wkThhzN
And here's the sample code to make that. Everything is done via code, so no #IBOutlet or other connections needed. Also note that I used three images for the page views - "ex1", "ex2", "ex3":
View Controller
class StretchyHeaderViewController: UIViewController {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.contentInsetAdjustmentBehavior = .never
return v
}()
let stretchyView: UIView = {
let v = UIView()
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = .systemYellow
return v
}()
let stretchyViewHeight: CGFloat = 350.0
override func viewDidLoad() {
super.viewDidLoad()
// set to a greter-than-zero value if you want spacing between the "pages"
let opts = [UIPageViewController.OptionsKey.interPageSpacing: 0.0]
// instantiate the Page View controller
let pgVC = SamplePageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: opts)
// add it as a child controller
self.addChild(pgVC)
// safe unwrap
guard let pgv = pgVC.view else { return }
pgv.translatesAutoresizingMaskIntoConstraints = false
// add the page controller view to stretchyView
stretchyView.addSubview(pgv)
pgVC.didMove(toParent: self)
NSLayoutConstraint.activate([
// constrain page view controller's view on all 4 sides
pgv.topAnchor.constraint(equalTo: stretchyView.topAnchor),
pgv.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
pgv.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
pgv.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
])
[scrollView, stretchyView, contentView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
// add contentView and stretchyView to the scroll view
[stretchyView, contentView].forEach { v in
scrollView.addSubview(v)
}
// add scroll view to self.view
view.addSubview(scrollView)
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
// keep stretchyView's Top "pinned" to the Top of the scroll view FRAME
// so its Height will "stretch" when scroll view is pulled down
let stretchyTop = stretchyView.topAnchor.constraint(equalTo: frameG.topAnchor, constant: 0.0)
// priority needs to be less-than-required so we can "push it up" out of view
stretchyTop.priority = .defaultHigh
NSLayoutConstraint.activate([
// scroll view Top to view Top
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
// scroll view Leading/Trailing/Bottom to safe area
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
// constrain stretchy view Top to scroll view's FRAME
stretchyTop,
// stretchyView to Leading/Trailing of scroll view FRAME
stretchyView.leadingAnchor.constraint(equalTo: frameG.leadingAnchor, constant: 0.0),
stretchyView.trailingAnchor.constraint(equalTo: frameG.trailingAnchor, constant: 0.0),
// stretchyView Height - greater-than-or-equal-to
// so it can "stretch" vertically
stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: stretchyViewHeight),
// content view Leading/Trailing/Bottom to scroll view's CONTENT GUIDE
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
// content view Width to scroll view's FRAME
contentView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
// content view Top to scroll view's CONTENT GUIDE
// plus Height of stretchyView
contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: stretchyViewHeight),
// content view Top to stretchyView Bottom
contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor, constant: 0.0),
])
// add some content to the content view so we have something to scroll
addSomeContent()
}
func addSomeContent() {
// vertical stack view with 20 labels
// so we have something to scroll
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 32
stack.backgroundColor = .gray
stack.translatesAutoresizingMaskIntoConstraints = false
for i in 1...20 {
let v = UILabel()
v.text = "Label \(i)"
v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
v.heightAnchor.constraint(equalToConstant: 48.0).isActive = true
stack.addArrangedSubview(v)
}
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16.0),
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16.0),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16.0),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16.0),
])
}
}
Controller for each Page
class OnePageVC: UIViewController {
var image: UIImage = UIImage() {
didSet {
imgView.image = image
}
}
let imgView: UIImageView = {
let v = UIImageView()
v.backgroundColor = .systemBlue
v.contentMode = .scaleAspectFill
v.clipsToBounds = true
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.addSubview(imgView)
NSLayoutConstraint.activate([
// constrain image view to all 4 sides
imgView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
imgView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
imgView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
imgView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
])
}
}
Sample Page View Controller
class SamplePageViewController: UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
var controllers: [UIViewController] = []
override func viewDidLoad() {
super.viewDidLoad()
let imgNames: [String] = [
"ex1", "ex2", "ex3",
]
for i in 0..<imgNames.count {
let aViewController = OnePageVC()
if let img = UIImage(named: imgNames[i]) {
aViewController.image = img
}
self.controllers.append(aViewController)
}
self.dataSource = self
self.delegate = self
self.setViewControllers([controllers[0]], direction: .forward, animated: false)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
if let index = controllers.firstIndex(of: viewController), index > 0 {
return controllers[index - 1]
}
return nil
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
if let index = controllers.firstIndex(of: viewController), index < controllers.count - 1 {
return controllers[index + 1]
}
return nil
}
}
Edit
Looking at the code you posted in your question's Edit... it's a little tough, since I don't know what your ProfileDetailBio view is, but here are a couple tips to help debug this type of situation during development:
give your views contrasting background colors... makes it easy to see the frames when you run the app
if a subview fills its superview's width, make it a little narrower so you can see what's "behind / under" it
set .clipsToBounds = true on views you're using as "containers" - such as contentView... if a subview is then "missing" you know it has extended outside the bounds of the container
So, for your code...
// so we can see the contentView frame
contentView.backgroundColor = .systemYellow
// leave some space on the right-side of bio view, so we
// so we can see the contentView behind it
bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -100.0),
If you run the app, you will likely see that contentView only extends to the bottom of bio - not to the bottom of info.
If you then do this:
contentView.clipsToBounds = true
info will likely not be visible at all.
Checking your constraints, you have:
bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Info table
info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
where it should be:
// no bio bottom anchor
//bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// this is correct
// Info table
info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
// add this
info.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
Run the app, and you should now again see info, and contentView extends to the bottom of info.
Assuming bio and info height combined are tall enough to require scrolling, you can undo the "debug / dev" changes and you should be good to go.

Disable Scrolling of UIScrollView on part of the ScrollView.frame

Hello hope everyone is safe. I have a ViewController(vc) which contains a UIScrollView(scrlView). The scrlView contains two other ViewControllers(vc1 & vc2). On the vc1 I have a button which pressed adds a subview(subViewVc1) to vc1. In order to not be shown on the other scrlView page on delegation begin dragging I remove the subviewVc1. The problem I have is that I can't deactivate scrolling of the scrlView where the subViewVc1 frame is.
I have tried multiple ways as subclassing the scrollview as modifying touchesBegan, but touchesBegan recognises a touch, if the user perform even a small drag the gesture is not recognised anymore. I have tried to add a swipe gesture recogniser but I realised it interfere with the scrollview gesture.
Anybody has any idea on what to do?
Here is one way this can be achieved, instead of disabling a specific frame, you can disable interaction on over a specific view:
SubClass the UIView you add on button tap with no real implementation but just for recognition - you can also use view tags if you prefer
SubClass the UIScrollView and implement touchesShouldCancel to cancel scrolling when interacting with a specific view
Set scrollView.delaysContentTouches = false
Implementation
First I tried to recreate your situation, I created the child view controller class with a button which will go into the scrollview:
class ChildVC: UIViewController
{
let button = UIButton(type: .system)
override func viewDidLoad()
{
super.viewDidLoad()
view.backgroundColor = .yellow
configureButton()
}
private func configureButton()
{
button.setTitle("Add View", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self,
action: #selector(didTapAddView),
for: .touchUpInside)
view.addSubview(button)
view.addConstraints([
button.leadingAnchor.constraint(equalTo: view.leadingAnchor),
button.bottomAnchor.constraint(equalTo: view.bottomAnchor),
button.trailingAnchor.constraint(equalTo: view.trailingAnchor),
button.heightAnchor.constraint(equalToConstant: 50)
])
}
#objc
private func didTapAddView()
{
let customView = UIView()
customView.backgroundColor = .white
customView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(customView)
view.addConstraints([
customView.leadingAnchor.constraint(equalTo: view.leadingAnchor,
constant: 16),
customView.topAnchor.constraint(equalTo: view.topAnchor,
constant: 16),
customView.trailingAnchor.constraint(equalTo: view.trailingAnchor,
constant: -16),
customView.bottomAnchor.constraint(equalTo: button.topAnchor,
constant: -16)
])
}
}
Then I created your container view controller with the scroll view and embedded the child vc into the view controller:
class ScrollTestViewController: UIViewController
{
private let scrollView = UIScrollView()
private let childVC1 = ChildVC()
private let childVC2 = ChildVC()
private let childVCHeight: CGFloat = 250
private let childVCWidth: CGFloat = UIScreen.main.bounds.width
override func viewDidLoad()
{
super.viewDidLoad()
view.backgroundColor = .white
title = "Scroll Test"
configureScrollView()
configureChildVC1()
configureChildVC2()
}
override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()
scrollView.contentSize = CGSize(width: childVC1.view.bounds.width * 2,
height: scrollView.bounds.height)
}
private func configureScrollView()
{
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
view.addConstraints([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func configureChildVC1()
{
addChild(childVC1)
childVC1.view.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(childVC1.view)
view.addConstraints([
childVC1.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
childVC1.view.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
childVC1.view.widthAnchor.constraint(equalToConstant: childVCWidth),
childVC1.view.heightAnchor.constraint(equalToConstant: childVCHeight)
])
}
private func configureChildVC2()
{
addChild(childVC2)
childVC2.view.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(childVC2.view)
view.addConstraints([
childVC2.view.leadingAnchor.constraint(equalTo: childVC1.view.trailingAnchor),
childVC2.view.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
childVC2.view.widthAnchor.constraint(equalToConstant: childVCWidth),
childVC2.view.heightAnchor.constraint(equalToConstant: childVCHeight)
])
}
}
After doing this, you will get this result:
No different, user can scroll anywhere even when swiping on top of the newly added view.
So I made these changes:
Create a custom view subclass so I can recognize which view I am dragging over
class CustomView: UIView
{
}
Create a custom scrollview to disable interaction over specific views
fileprivate class CustomScrollView: UIScrollView
{
override func touchesShouldCancel(in view: UIView) -> Bool
{
// Just for demo, I added this
if view is CustomView
{
print("Scroll disabled, tapping custom view")
}
else
{
print("Scroll enabled")
}
// You only need this line
return !(view is CustomView)
}
}
Make the changes in the container view controller with the scroll view
// Replace private let scrollView = UIScrollView() with
private let scrollView = CustomScrollView()
// Add this as well
scrollView.delaysContentTouches = false
Make changes in the child view controllers to use Custom view when tapping the button
#objc
private func didTapAddView()
{
let customView = CustomView()
customView.backgroundColor = .white
customView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(customView)
view.addConstraints([
customView.leadingAnchor.constraint(equalTo: view.leadingAnchor,
constant: 16),
customView.topAnchor.constraint(equalTo: view.topAnchor,
constant: 16),
customView.trailingAnchor.constraint(equalTo: view.trailingAnchor,
constant: -16),
customView.bottomAnchor.constraint(equalTo: button.topAnchor,
constant: -16)
])
}
Now when you swipe over the view, it will prevent you from scrolling
Update based on Mihai's (OP's) comments
Do you have any idea how I could do it for a UIViewController instead
of UIView, Im asking because the view is currently UIViewController
and I lose some specifications about it in transforming it to an
UIView.
So on the button press the view will add a UIViewController instead of
a UIView
A scrollview can add UIViews to it's view hierarchy therefore you can only check if the view you are scrolling on is a specific type of view.
However, I still think the solution can work if you want to add a view controller on a button tap instead of a UIView.
Keep the custom view as it is:
fileprivate class CustomView: UIView
{
}
Create a view controller subclass and change the default view to be the custom view subclass
fileprivate class PurpleVC: UIViewController
{
override func loadView()
{
super.loadView()
// Change the default view from a UIView
// to be of type CustomView
view = CustomView()
view.backgroundColor = .purple
}
}
Add the new view controller on button tap instead of UIView
#objc
private func didTapAddView()
{
let customVC = PurpleVC()
customVC.view.translatesAutoresizingMaskIntoConstraints = false
addChild(customVC)
view.addSubview(customVC.view)
view.addConstraints([
customVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor,
constant: 16),
customVC.view.topAnchor.constraint(equalTo: view.topAnchor,
constant: 16),
customVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor,
constant: -16),
customVC.view.bottomAnchor.constraint(equalTo: button.topAnchor,
constant: -16)
])
}
No change to custom scroll view as it should still detect the custom view which is the main view inside your view controller
fileprivate class CustomScrollView: UIScrollView
{
override func touchesShouldCancel(in view: UIView) -> Bool
{
// Just for demo, I added this
if view is CustomView
{
print("Scroll disabled, tapping custom view")
}
else
{
print("Scroll enabled")
}
// You only need this line
return !(view is CustomView)
}
}

UIView resize to fit labels inside of it

I have a UIView that I'm appending into a stack view on my main page of my app
This is the class of my view:
class MyCustomView: UIView {
public let leftLabel: UILabel = UILabel(frame: .zero)
public let rightLabel: UILabel = UILabel(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(leftLabel)
addSubview(rightLabel)
leftLabel.translatesAutoresizingMaskIntoConstraints = false
rightLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
leftLabel.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.4),
leftLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
leftLabel.topAnchor.constraint(equalTo: topAnchor),
leftLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
rightLabel.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6),
rightLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
rightLabel.topAnchor.constraint(equalTo: topAnchor),
rightLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
])
leftLabel.text = "Short string"
rightLabel.text = "Short string too"
}
}
And I append to my main stack view with:
let myCustomView = MyCustomView(frame: .zero)
stackView.addArrangedSubview(myCustomView)
This loads in my label's correctly and resizes everything as I'd want.
However, in my main class, I am updating myCustomView.rightLabel.text = <New Way Longer Text That Takes 2 Lines Instead of One>
The text is updating properly, but my myCustomView size is not resizing, so part of the text is just being cut-off
I have tried following other answers on here, but none of them seem to work for me.
Am I missing something small in order to force the resize of the customView to fit the label inside of it?
Thank you in advance
Your code does not show that you set the .numberOfLines in the label(s) to 0 to allow for multi-line labels.
Adding only that, should allow your labels to grow in height and to expand your custom view. However... that will also make both labels expand to the size of the tallest label, resulting in the text of the "shorter" label being vertically centered (I added background colors to make it easy to see the frames / bounds of the views):
If you constrain the Bottom of your custom view to the Bottom of each label at greaterThanOrEqualTo you can keep the labels "top-aligned":
You can run this code directly in a Playground page to see the results:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
class MyCustomView: UIView {
public let leftLabel: UILabel = UILabel(frame: .zero)
public let rightLabel: UILabel = UILabel(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(leftLabel)
addSubview(rightLabel)
// background colors so we can see the view frames
backgroundColor = .cyan
leftLabel.backgroundColor = .yellow
rightLabel.backgroundColor = .green
// we want multi-line labels
leftLabel.numberOfLines = 0
rightLabel.numberOfLines = 0
// use auto-layout
leftLabel.translatesAutoresizingMaskIntoConstraints = false
rightLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// constrain to top
leftLabel.topAnchor.constraint(equalTo: topAnchor),
// constrain to left
leftLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
// constrain width = 40%
leftLabel.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.4),
// constrain to top
rightLabel.topAnchor.constraint(equalTo: topAnchor),
// constrain to right
rightLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
// constrain width = 60%
rightLabel.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6),
// constrain bottom of view (self) to >= 0 from bottom of leftLabel
bottomAnchor.constraint(greaterThanOrEqualTo: leftLabel.bottomAnchor, constant: 0.0),
// constrain bottom of view (self) to >= 0 from bottom of rightLabel
bottomAnchor.constraint(greaterThanOrEqualTo: rightLabel.bottomAnchor, constant: 0.0),
])
leftLabel.text = "Short string"
rightLabel.text = "Short string too"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class MyViewController : UIViewController {
var theButton: UIButton = {
let b = UIButton()
b.setTitle("Tap Me", for: .normal)
b.translatesAutoresizingMaskIntoConstraints = false
b.backgroundColor = .red
return b
}()
var theStackView: UIStackView = {
let v = UIStackView()
v.translatesAutoresizingMaskIntoConstraints = false
v.axis = .vertical
v.spacing = 8
v.distribution = .equalSpacing
return v
}()
var myView = MyCustomView()
// on button tap, change the text in the label(s)
#objc func didTap(_ sender: Any?) -> Void {
myView.leftLabel.text = "Short string with\nA\nB\nC\nD\nE"
myView.rightLabel.text = "Short string too\nA\nB"
}
override func loadView() {
let view = UIView()
self.view = view
view.backgroundColor = .white
view.addSubview(theButton)
// constrain button to Top: 32 and centerX
NSLayoutConstraint.activate([
theButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 32.0),
theButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 0.0),
])
view.addSubview(theStackView)
// constrain stack view to Top: 100 and Leading/Trailing" 0
// no Bottom or Height constraint
NSLayoutConstraint.activate([
theStackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 100.0),
theStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
theStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
])
theStackView.addArrangedSubview(myView)
// add an action for the button tap
theButton.addTarget(self, action: #selector(didTap(_:)), for: .touchUpInside)
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

StackView not filling Scrollview

I have seen several questions like this but none of the answers have managed to fix it for me.
I have a view at the bottom of the screen that contains a scrollView that contains a stackView that will be populated with buttons.
My view is built programmatically like so:
import UIKit
class BottomBar: UIView {
typealias BindTap = ((String) -> Void)?
private let scrollView = UIScrollView()
private let buttonsStackView = UIStackView()
var onTap: BindTap
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setUpViews()
setUpLayout()
}
private func setUpViews() {
backgroundColor = .cyan
scrollView.backgroundColor = .red
buttonsStackView.backgroundColor = .green
buttonsStackView.alignment = .fill
buttonsStackView.distribution = .equalSpacing
buttonsStackView.axis = .horizontal
buttonsStackView.spacing = 5
scrollView.addSubview(buttonsStackView)
addSubview(scrollView)
}
private func setUpLayout() {
buttonsStackView.pinToSuperview(edges: [.top, .bottom, .left, .right],
constant: 5,
priority: .defaultHigh)
scrollView.pinToSuperview(edges: [.top, .bottom, .left, .right],
constant: 0,
priority: .defaultHigh)
}
func addModelButtons(models: [Model]) {
models.forEach { model in
let modelButton = UIButton()
modelButton.backgroundColor = .lightGray
modelButton.setTitle(model.fileName, for: .normal)
modelButton.addTarget(self, action: #selector(modelButtonTapped), for: .touchUpInside)
buttonsStackView.addArrangedSubview(modelButton)
if let first = models.first,
first.fileName == model.fileName {
updateSelectedButtonColor(modelButton)
}
}
}
#objc private func modelButtonTapped(button: UIButton) {
guard let modelName = button.titleLabel?.text else { return }
onTap?(modelName)
resetButtonColors()
updateSelectedButtonColor(button)
}
private func resetButtonColors() {
for case let button as UIButton in buttonsStackView.subviews {
button.backgroundColor = .lightGray
}
}
private func updateSelectedButtonColor(_ button: UIButton) {
button.backgroundColor = .darkGray
}
}
I cant see what is missing. I've added a picture so you can see that the stackView is wrapping around the buttons and not filling the scrollview.
Any help would be great. Im sure its a simple fix i just cant see it!
Here's the thing to understand about scrollViews. Unless you give the content area of a scrollView an explicit size, it will determine its size from its subviews.
In your case, you've told it that your stackView is 5 points away from the edges of the scrollView. That ties the size of the stackView to the size of the content area of the scrollView. At this point, the stackView is controlling the size of the scrollable area of the scrollView. Since your stackView only has 2 buttons, the stackView shrinks to the size of those two buttons and the scrollable area of the scrollView is 10 wider than that. Since the buttons are small, this does not fill the screen.
What you want is that the buttons stretch to fill the apparent size of the scrollView. In order for that to happen, you need to tell Auto Layout that the stackView's width must be greater than or equal to the width of the scrollView - 10.
Add this constraint after scrollView.addSubview(buttonsStackView):
buttonsStackView.widthAnchor.constraint(greaterThanOrEqualTo: scrollView.widthAnchor, multiplier: 1, constant: -10).isActive = true