I have a grid view, it's like a chess board. The hierarchy is this :
UIScrollView
-- UIView
---- [UIViews]
Here's a screenshot.
Knowing that a tile has width and height of tileSide, how can I find a way to programmatically zoom in focusing on the area with the blue border? I need basically to find the right zoomScale.
What I'm doing is this :
let centralTilesTotalWidth = tileSide * 5
zoomScale = CGFloat(centralTilesTotalWidth) / CGFloat(actualGridWidth) + 1.0
where actualGridWidth is defined as tileSide multiplied by the number of columns. What I'm obtaining is to see almost seven tiles, not the five I want to see.
Keep also present that the contentView (the brown one) has a full screen frame, like the scroll view in which it's contained.
You can do this with zoom(to rect: CGRect, animated: Bool) (Apple docs).
Get the frames of the top-left and bottom-right tiles
convert then to contentView coordinates
union the two rects
call zoom(to:...)
Here is a complete example - all via code, no #IBOutlet or #IBAction connections - so just create a new view controller and assign its custom class to GridZoomViewController:
class GridZoomViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
return v
}()
let contentView: UIView = {
let v = UIView()
return v
}()
let gridStack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.distribution = .fillEqually
return v
}()
var selectedTiles: [TileView] = [TileView]()
override func viewDidLoad() {
super.viewDidLoad()
[gridStack, contentView, scrollView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
var bColor: Bool = false
// create a 9x7 grid of tile views, alternating cyan and yellow
for _ in 1...7 {
// horizontal stack view
let rowStack = UIStackView()
rowStack.translatesAutoresizingMaskIntoConstraints = false
rowStack.axis = .horizontal
rowStack.distribution = .fillEqually
for _ in 1...9 {
// create a tile view
let v = TileView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = bColor ? .cyan : .yellow
v.origColor = v.backgroundColor!
bColor.toggle()
// add a tap gesture recognizer to each tile view
let g = UITapGestureRecognizer(target: self, action: #selector(self.tileTapped(_:)))
v.addGestureRecognizer(g)
// add it to the row stack view
rowStack.addArrangedSubview(v)
}
// add row stack view to grid stack view
gridStack.addArrangedSubview(rowStack)
}
// add subviews
contentView.addSubview(gridStack)
scrollView.addSubview(contentView)
view.addSubview(scrollView)
let padding: CGFloat = 20.0
// respect safe area
let g = view.safeAreaLayoutGuide
// for scroll view content constraints
let cg = scrollView.contentLayoutGuide
// let grid width shrink if 7:9 ratio is too tall for view
let wAnchor = gridStack.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 1.0)
wAnchor.priority = .defaultHigh
NSLayoutConstraint.activate([
// constrain scroll view to view (safe area), all 4 sides with "padding"
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: padding),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: padding),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -padding),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -padding),
// constrain content view to scroll view contentLayoutGuide, all 4 sides
contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
// content view width and height equal to scroll view width and height
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0),
contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: 0.0),
// activate gridStack width anchor
wAnchor,
// gridStack height = gridStack width at 7:9 ration (7 rows, 9 columns)
gridStack.heightAnchor.constraint(equalTo: gridStack.widthAnchor, multiplier: 7.0 / 9.0),
// make sure gridStack height is less than or equal to content view height
gridStack.heightAnchor.constraint(lessThanOrEqualTo: contentView.heightAnchor),
// center gridStack in contentView
gridStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0.0),
gridStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0),
])
// so we can see the frames
view.backgroundColor = .blue
scrollView.backgroundColor = .orange
contentView.backgroundColor = .brown
// delegate and min/max zoom scales
scrollView.delegate = self
scrollView.minimumZoomScale = 0.25
scrollView.maximumZoomScale = 5.0
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return contentView
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: nil, completion: {
_ in
if self.selectedTiles.count == 2 {
// re-zoom the content on size change (such as device rotation)
self.zoomToSelected()
}
})
}
#objc
func tileTapped(_ gesture: UITapGestureRecognizer) -> Void {
// make sure it was a Tile View that sent the tap gesture
guard let tile = gesture.view as? TileView else { return }
if selectedTiles.count == 2 {
// if we already have 2 selected tiles, reset everything
reset()
} else {
// add this tile to selectedTiles
selectedTiles.append(tile)
// if it's the first one, green background, if it's the second one, red background
tile.backgroundColor = selectedTiles.count == 1 ? UIColor(red: 0.0, green: 0.75, blue: 0.0, alpha: 1.0) : .red
// if it's the second one, zoom
if selectedTiles.count == 2 {
zoomToSelected()
}
}
}
func zoomToSelected() -> Void {
// get the stack views holding tile[0] and tile[1]
guard let sv1 = selectedTiles[0].superview,
let sv2 = selectedTiles[1].superview else {
fatalError("problem getting superviews! (this shouldn't happen)")
}
// convert tile view frames to content view coordinates
let r1 = sv1.convert(selectedTiles[0].frame, to: contentView)
let r2 = sv2.convert(selectedTiles[1].frame, to: contentView)
// union the two frames to get one larger rect
let targetRect = r1.union(r2)
// zoom to that rect
scrollView.zoom(to: targetRect, animated: true)
}
func reset() -> Void {
// reset the tile views to their original colors
selectedTiles.forEach {
$0.backgroundColor = $0.origColor
}
// clear the selected tiles array
selectedTiles.removeAll()
// zoom back to full grid
scrollView.zoom(to: scrollView.bounds, animated: true)
}
}
class TileView: UIView {
var origColor: UIColor = .white
}
It will look like this to start:
The first "tile" you tap will turn green:
When you tap a second tile, it will turn red and we'll zoom in to that rectangle:
Tapping a third time will reset to starting grid.
Related
I have a view controller with the below UI layout.
There is a header view at the top with 3 labels, a footer view with 2 buttons at the bottom and an uitableview inbetween header view and footer view. The uitableview is dynamically loaded and on average has about 6 tableview cells. One of the buttons in the footer view is take screenshot button where i need to take the screenshot of full tableview. In small devices like iPhone 6, the height of the table is obviously small as it occupies the space between header view and footer view. So only 4 cells are visible to the user and as the user scrolls others cells are loaded into view. If the user taps take screen shot button without scrolling the table view, the last 2 cells are not captured in the screenshot. The current implementation tried to negate this by changing table view frame to table view content size before capturing screenshot and resetting frame after taking screenshot, but this approach is not working starting iOS 13 as the table view content size returns incorrect values.
Current UI layout implementation
Our first solution is to embed the tableview inside the scrollview and have the tableview's scroll disabled. By this way the tableview will be forced to render all cells at once. We used the below custom table view class to override intrinsicContentSize to make the tableview adjust itself to correct height based on it contents
class CMDynamicHeightAdjustedTableView: UITableView {
override var intrinsicContentSize: CGSize {
self.layoutIfNeeded()
return self.contentSize
}
override var contentSize: CGSize {
didSet {
self.invalidateIntrinsicContentSize()
}
}
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
}
Proposed UI implementation
But we are little worried about how overriding intrinsicContentSize could affect performance and other apple's internal implementations
So our second solution is to set a default initial height constraint for tableview and observe the tableview's content size keypath and update the tableview height constraint accordingly. But the content size observer gets called atleast 12-14 times before the screen elements are visible to the user.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.confirmationTableView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
if object is UITableView {
if let newvalue = change?[.newKey], let newSize = newvalue as? CGSize {
self.confirmationTableViewHeightConstraint.constant = newSize.height
}
}
}
}
Will the second approach impact performance too?
What is the better approach of the two?
Is there any alternate solution?
I am not sure, but if I understood correctly when you screenshot the TableView the last 2 cells are not loaded because of the tableview being between the Header and Footer. Here are two options I would consider:
Option 1
Try to make the TableView frame start from the Header and have the height of the Unscreen.main.bounds.height - the Header view frame. This would mean that the tableView will expand toward the end of the screen. Then add the Footer over the tableView in the desired relation.
Option 2
Try before screenshooting, to reloadRows at two level below the current Level. You can get the current indexPath of the UITableView, when the TableView reloads it from its delegate, store it somewhere always the last indexPath used, and when screenshot reload the two below.
You can "temporarily" change the height of your table view, force it to update, render it to a UIImage, and then set the height back.
Assuming you have your "Header" view constrained to the top, your "Footer" view constrained to the bottom, and your table view constrained between them...
Add a class var/property for the table view's bottom constraint:
var tableBottomConstraint: NSLayoutConstraint!
then set that constraint:
tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
When you want to "capture" the table:
func captureTableView() -> UIImage {
// save the table view's bottom constraint's constant
// and the contentOffset y position
let curConstant = tableBottomConstraint.constant
let curOffset = tableView.contentOffset.y
// make table view really tall, to guarantee all rows will fit
tableBottomConstraint.constant = 20000
// force it to update
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
// get the image
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext();
// set table view state back to what it was
tableBottomConstraint.constant = curConstant
tableView.contentOffset.y = curOffset
return image
}
Here is a complete example you can run to test it:
class SimpleCell: UITableViewCell {
let theLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.backgroundColor = .yellow
return v
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
theLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(theLabel)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
theLabel.topAnchor.constraint(equalTo: g.topAnchor),
theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor),
theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor),
])
}
}
class TableCapVC: UIViewController, UITableViewDataSource, UITableViewDelegate {
let tableView = UITableView()
// let's use 12 rows, each with 1, 2, 3 or 4 lines of text
// so it will definitely be too many rows to see on the screen
let numRows: Int = 12
var tableBottomConstraint: NSLayoutConstraint!
// we'll use this to display that captured table view image
let resultHolder = UIView()
let resultImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let headerView = myHeaderView()
let footerView = myFooterView()
[headerView, tableView, footerView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
// we will use this to change the bottom constraint of the table view
// when we want to capture it
tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
NSLayoutConstraint.activate([
// constrain "header" view at the top
headerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
headerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
headerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
// constrain "fotter" view at the bottom
footerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
footerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
footerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain table view between header and footer views
tableView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 0.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
tableBottomConstraint,
])
tableView.register(SimpleCell.self, forCellReuseIdentifier: "c")
tableView.dataSource = self
tableView.delegate = self
// we'll add a UIImageView (in a "holder" view) on top of the table
// then show/hide it to see the results of
// the table capture
resultImageView.backgroundColor = .gray
resultImageView.layer.borderColor = UIColor.cyan.cgColor
resultImageView.layer.borderWidth = 1
resultImageView.layer.cornerRadius = 16.0
resultImageView.layer.shadowColor = UIColor.black.cgColor
resultImageView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
resultImageView.layer.shadowRadius = 8
resultImageView.layer.shadowOpacity = 0.9
resultImageView.contentMode = .scaleAspectFit
resultHolder.alpha = 0.0
resultHolder.translatesAutoresizingMaskIntoConstraints = false
resultImageView.translatesAutoresizingMaskIntoConstraints = false
resultHolder.addSubview(resultImageView)
view.addSubview(resultHolder)
NSLayoutConstraint.activate([
// cover everything with the clear "holder" view
resultHolder.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
resultHolder.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
resultHolder.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
resultHolder.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
resultImageView.topAnchor.constraint(equalTo: resultHolder.topAnchor, constant: 20.0),
resultImageView.leadingAnchor.constraint(equalTo: resultHolder.leadingAnchor, constant: 20.0),
resultImageView.trailingAnchor.constraint(equalTo: resultHolder.trailingAnchor, constant: -20.0),
resultImageView.bottomAnchor.constraint(equalTo: resultHolder.bottomAnchor, constant: -20.0),
])
// tap image view / holder view when showing to hide it
let t = UITapGestureRecognizer(target: self, action: #selector(hideImage))
resultHolder.addGestureRecognizer(t)
}
func myHeaderView() -> UIView {
let v = UIView()
v.backgroundColor = .systemBlue
let sv = UIStackView()
sv.axis = .vertical
sv.spacing = 4
let strs: [String] = [
"\"Header\" and \"Footer\" views",
"are separate views - they are not",
".tableHeaderView / .tableFooterView",
]
strs.forEach { str in
let label = UILabel()
label.text = str
label.textAlignment = .center
label.font = .systemFont(ofSize: 13.0, weight: .regular)
label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
sv.addArrangedSubview(label)
}
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
])
return v
}
func myFooterView() -> UIView {
let v = UIView()
v.backgroundColor = .systemPink
let sv = UIStackView()
sv.axis = .horizontal
sv.spacing = 12
sv.distribution = .fillEqually
let btn1: UIButton = {
var cfg = UIButton.Configuration.filled()
cfg.title = "Capture Table"
let b = UIButton(configuration: cfg)
b.addTarget(self, action: #selector(btn1Action(_:)), for: .touchUpInside)
return b
}()
let btn2: UIButton = {
var cfg = UIButton.Configuration.filled()
cfg.title = "Another Button"
let b = UIButton(configuration: cfg)
b.addTarget(self, action: #selector(btn2Action(_:)), for: .touchUpInside)
return b
}()
sv.addArrangedSubview(btn1)
sv.addArrangedSubview(btn2)
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
])
return v
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numRows
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! SimpleCell
let nLines = indexPath.row % 4
var s: String = "Row: \(indexPath.row)"
for i in 0..<nLines {
s += "\nLine \(i+2)"
}
c.theLabel.text = s
return c
}
#objc func btn1Action(_ sender: UIButton) {
let img = captureTableView()
print("TableView Image Captured - size:", img.size)
// do something with the tableView capture
// maybe save it to documents folder?
// for this example, we will show it
resultImageView.image = img
UIView.animate(withDuration: 0.5, animations: {
self.resultHolder.alpha = 1.0
})
}
#objc func hideImage() {
UIView.animate(withDuration: 0.5, animations: {
self.resultHolder.alpha = 0.0
})
}
#objc func btn2Action(_ sender: UIButton) {
print("Another Button Tapped")
}
func captureTableView() -> UIImage {
// save the table view's bottom constraint's constant
// and the contentOffset y position
let curConstant = tableBottomConstraint.constant
let curOffset = tableView.contentOffset.y
// make table view really tall, to guarantee all rows will fit
tableBottomConstraint.constant = 20000
// force it to update
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
// get the image
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext();
// set table view state back to what it was
tableBottomConstraint.constant = curConstant
tableView.contentOffset.y = curOffset
return image
}
}
We give the table 12 rows, each with 1, 2, 3 or 4 lines of text so it will definitely be too many rows to see on the screen. Tapping on the "Capture Table" button will capture the table to a UIImage and then display that image. Tap on the image to dismiss it:
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
What’s the best way to dynamically lock scrolling in a UIScrollView to the vertical axis depending on the zoom scale?
I want to allow scrolling a large canvas in any direction when zoomed out (scrollView.zoomScale < 1.0)
but prevent horizontal scrolling completely when zoomed in (scrollView.zoomScale == 1.0).
The challenge here is that UIScrollView doesn’t seem to have a built-in setting to limit scrolling to one direction if the contentView is larger than the viewport in both directions. I would like to use the same large contentView but disallow horizontal scrolling when zoomed in.
(I know about scrollView.isDirectionalLockEnabled, but that’s not what I need: It only checks whether the user’s pan gesture has a dominant scrolling direction and then dynamically locks scrolling to either direction.)
Thanks!
If I understand your goal correctly...
You have a "contentView" that is larger than the scroll view
if the zoom scale is 1.0, only allow vertical scrolling
if the zoom scale is less than 1.0, allow both vertical and horizontal scrolling
So, if we have a scroll view frame size of 388 x 661 and a "contentView" with a size of 2100 x 2100, we start like this at zoom scale 1.0 (the bright-green is the scroll view frame):
and only vertical scrolling is allowed.
If the user zooms-out to, say, 0.8 scale:
both vertical and horizontal scrolling is allowed.
If the user then zooms-in back to 1.0 scale:
we're back to only vertical scrolling.
You can accomplish that by conforming your controller to UIScrollViewDelegate, assign self as the scrollView's delegate, add a "last scrollView content offset X" var, and then implement scrollViewDidScroll():
var lastOffsetX: CGFloat = 0
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// if zoom scale is 1.0
// don't allow horizontal scrolling
if scrollView.zoomScale == 1.0 {
scrollView.contentOffset.x = lastOffsetX
return
}
// zoom scale is less than 1.0, so
// allow the scroll and update lastX
lastOffsetX = scrollView.contentOffset.x
}
Here's a complete example you can try out:
class ExampleViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .systemYellow
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = .systemTeal
return v
}()
let infoLabel: UILabel = {
let v = UILabel()
return v
}()
// we'll use this to track the current content X offset
var lastOffsetX: CGFloat = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
scrollView.addSubview(contentView)
view.addSubview(scrollView)
view.addSubview(infoLabel)
[contentView, scrollView, infoLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
// let's add a 8 x 8 "grid" of labels to the content view
let outerVerticalStack = UIStackView()
outerVerticalStack.axis = .vertical
outerVerticalStack.spacing = 20
outerVerticalStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(outerVerticalStack)
var j: Int = 1
for _ in 1...8 {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 20
rowStack.distribution = .fillEqually
for _ in 1...8 {
let v = UILabel()
v.font = .systemFont(ofSize: 48.0, weight: .regular)
v.text = "\(j)"
v.textAlignment = .center
v.backgroundColor = .green
v.widthAnchor.constraint(equalToConstant: 240.0).isActive = true
v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
rowStack.addArrangedSubview(v)
j += 1
}
outerVerticalStack.addArrangedSubview(rowStack)
}
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -120.0),
contentView.topAnchor.constraint(equalTo: contentG.topAnchor),
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
outerVerticalStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20.0),
outerVerticalStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20.0),
outerVerticalStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20.0),
outerVerticalStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.0),
// put the info label below the scroll view
infoLabel.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 20.0),
infoLabel.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
infoLabel.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
])
// we'll update min zoom in viewDidAppear
// (after all views have been laid out)
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 1.0
// we need to disable zoom bouncing, or
// we get really bad positioning effect
// when zooming in past 1.0
scrollView.bouncesZoom = false
// assign the delegate
scrollView.delegate = self
// update the info label
updateInfo()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// update min zoom scale so we can only "zoom out" until
// the content view fits the scroll view frame
if scrollView.minimumZoomScale == 1.0 {
print(contentView.frame.size)
let xScale = scrollView.frame.width / contentView.frame.width
let yScale = scrollView.frame.height / contentView.frame.height
scrollView.minimumZoomScale = min(xScale, yScale)
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return contentView
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// if zoom scale is 1.0
// don't allow horizontal scrolling
if scrollView.zoomScale == 1.0 && !scrollView.isZooming {
scrollView.contentOffset.x = lastOffsetX
return
}
// zoom scale is less than 1.0, so
// allow the scroll and update lastX
lastOffsetX = scrollView.contentOffset.x
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
updateInfo()
}
func updateInfo() {
let s = String(format: "%0.4f", scrollView.zoomScale)
infoLabel.text = "Zoom Scale: \(s)"
}
}
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 added a stackView to a scrollView. I set the width, X, and Y constraints of the scrollView same as main view, with a fixed height of 50. For the stack constraints, I did the same thing but relative to the scrollView instead of the view.
My issue is when I add UIImageViews to my stack (all images are 50 x 50). I need the stack to show only the first three UIImageViews, and scroll horizontally if there are more than 3. So far, my stack always shows all the UIImageViews.
Any suggestion is appreciated. Been working on this for 2 days now. THANKS!
What you probably want to do...
constrain all 4 sides of the stack view to the scroll view's Content Layout Guide
constrain the Height of the stack view equal to the Height of the scroll view's Frame Layout Guide
do NOT constrain the Width of the stack view
set the stack view's Distribution to Fill
Create a "tab view" - here's an example with a 50 x 50 centered image view, rounded top corners and a 1-pt outline:
We can create that with this simple class:
class MyTabView: UIView {
let imgView = UIImageView()
init(with image: UIImage) {
super.init(frame: .zero)
imgView.image = image
commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
imgView.translatesAutoresizingMaskIntoConstraints = false
// light gray background
backgroundColor = UIColor(white: 0.9, alpha: 1.0)
addSubview(imgView)
NSLayoutConstraint.activate([
// centered
imgView.centerXAnchor.constraint(equalTo: centerXAnchor),
imgView.centerYAnchor.constraint(equalTo: centerYAnchor),
// 50x50
imgView.widthAnchor.constraint(equalToConstant: 50.0),
imgView.heightAnchor.constraint(equalTo: imgView.widthAnchor),
])
// a little "styling" for the "tab"
clipsToBounds = true
layer.cornerRadius = 12
layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
layer.borderWidth = 1
layer.borderColor = UIColor.darkGray.cgColor
}
}
For each "tab" that we add to the stack view, we'll set its Width constraint equal to the scroll view's Frame Layout Guide widthAnchor with multiplier: 1.0 / 3.0. That way each "tab view" will be 1/3rd the width of the scroll view:
With 1, 2 or 3 "tabs" there will be no horizontal scrolling, because they all fit within the frame.
Once we have more than 3 "tabs" the stack view's width will exceed the width of the frame, and we'll have horizontal scrolling:
Here's the view controller I used for that. It creates 9 "tab images"... starts with a single "tab"... each tap will ADD a "tab" until we have all 9, at which point each tap will REMOVE a "tab":
class StackAsTabsViewController: UIViewController {
let stackView: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.distribution = .fill
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let scrollView: UIScrollView = {
let v = UIScrollView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
// a label to show what's going on
let statusLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
// array to hold our "tab" images
var images: [UIImage] = []
// we'll add a "tab" on each tap
// until we reach the end of the images array
// then we'll remove a "tab" on each tap
// until we're back to a single "tab"
var isAdding: Bool = true
override func viewDidLoad() {
super.viewDidLoad()
// add the "status" label
view.addSubview(statusLabel)
// add stackView to scrollView
scrollView.addSubview(stackView)
// add scrollView to view
view.addSubview(scrollView)
// respect safe area
let g = view.safeAreaLayoutGuide
// scrollView Content and Frame Layout Guides
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// constrain scrollView Top / Leading / Trailing
scrollView.topAnchor.constraint(equalTo: g.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
// height = 58 (image will be 50x50, so a little top and bottom padding)
scrollView.heightAnchor.constraint(equalToConstant: 58.0),
// constrain stackView all 4 sides to scrollView Content Layout Guide
stackView.topAnchor.constraint(equalTo: contentG.topAnchor),
stackView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
// stackView Height equal to scrollView Frame Height
stackView.heightAnchor.constraint(equalTo: frameG.heightAnchor),
// statusLabel in the middle of the view
statusLabel.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 40.0),
statusLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
statusLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0)
])
// let's create 9 images using SF Symbols
for i in 1...9 {
guard let img = UIImage(systemName: "\(i).circle.fill") else {
fatalError("Could not create images!!!")
}
images.append(img)
}
// add the first "tab view"
self.updateTabs()
// tap anywhere in the view
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
#objc func gotTap(_ g: UITapGestureRecognizer) -> Void {
updateTabs()
}
func updateTabs() -> Void {
if isAdding {
// get the next image from the array
let img = images[stackView.arrangedSubviews.count]
// create a "tab view"
let tab = MyTabView(with: img)
// add it to the stackView
stackView.addArrangedSubview(tab)
let frameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// each "tab view" is 1/3rd the width of the scroll view frame
tab.widthAnchor.constraint(equalTo: frameG.widthAnchor, multiplier: 1.0 / 3.0),
// each "tab view" is the same height as the scroll view frame
tab.heightAnchor.constraint(equalTo: frameG.heightAnchor),
])
} else {
stackView.arrangedSubviews.last?.removeFromSuperview()
}
if stackView.arrangedSubviews.count == 1 {
isAdding = true
} else if stackView.arrangedSubviews.count == images.count {
isAdding = false
}
updateStatusLabel()
}
func updateStatusLabel() -> Void {
// we'll do this async, to make sure the views have been updated
DispatchQueue.main.async {
let numTabs = self.stackView.arrangedSubviews.count
var str = ""
if self.isAdding {
str += "Tap anywhere to ADD a tab"
} else {
str += "Tap anywhere to REMOVE a tab"
}
str += "\n\n"
str += "Number of tabs: \(numTabs)"
str += "\n\n"
if numTabs > 3 {
str += "Tabs WILL scroll"
} else {
str += "Tabs will NOT scroll"
}
self.statusLabel.text = str
}
}
}
Play with it, and see if that's what you're going for.