Reputation: 2351
I have a superview with an arbitrary amount of subviews. The app I'm creating has the ability to continually add subviews into this superview. I want the subviews to fill as much space as possible like shown in the pictures below. These subviews will have an aspect ratio of 5:8
My idea was to add one horizontal stack view inside of a vertical stackview initially. The number of inner stackviews would be equal to a variable which is set to the max number of cards per row(which would be √(numberOfViews)). When the number of subviews becomes greater than this variable, I could add another inner stack view. I would have to keep track of each inner stackview and make sure they almost all contain the same number of elements
I'm wondering if there is an easier solution. Also the math for this solution does not work for the case of sideways orientation.
Upvotes: 0
Views: 875
Reputation: 77682
This sounded like an interesting task, so I gave it a quick shot.
Here is one approach...
Think about the layout as columns and rows.
Start with all items (the added subviews) laid out in a single row, fitting the width of the container. Since the items are at a 5:8 ratio, we can calculate the height of the row.
So, decrement the number of columns - which effectively moves one item down to the next row, and recalculate. Each time we decrement the columns, the items will get wider... which will also make them taller, so...
Keep decrementing the number of columns until the rows are too tall to fit.
Here's an example with 7 items:
At this point, the rows no longer fit in the container, so we know that 3-columns gives us the max item size.
We're not quite done yet though...
Depending on the number of items and the size / ratio of the container, we might not be getting the largest possible size.
Here is an example with just 2 items. With 2 columns, we can fit another row... but in doing so, the items become much wider and taller, and won't fit in a single column:
If we manually lay out 1 column x 2 rows, we get this:
Each item is obviously larger in this layout than two items side-by-side.
So, after running our "columns by rows" calc, we need to run a "rows by columns" calc. Same process, but start with all items in a single column... decrementing the number of rows until they no longer fit:
With 4 rows, they fit, with 3 rows, they don't.
Now, we compare the max item size from the "columns by rows" calc with the max item size from the "rows by columns" calc, and use the larger result.
Here is a full example. All code (no @IBOutlets
or @IBActions
), so just add a new view controller and assign its custom class to ArrangeViewController
:
//
// ArrangeViewController.swift
//
// Created by Don Mag on 11/02/19.
//
import UIKit
class ArrangeViewController: UIViewController {
// Add a view button
let addButton: UIButton = {
let v = UIButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .yellow
v.setTitleColor(.blue, for: .normal)
v.setTitleColor(.lightGray, for: .highlighted)
v.setTitle("Add", for: .normal)
return v
}()
// Remove a view button
let remButton: UIButton = {
let v = UIButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .yellow
v.setTitleColor(.blue, for: .normal)
v.setTitleColor(.lightGray, for: .highlighted)
v.setTitle("Remove", for: .normal)
return v
}()
// horizontal stackview to hold the buttons
let btnsStack: UIStackView = {
let v = UIStackView()
v.translatesAutoresizingMaskIntoConstraints = false
v.axis = .horizontal
v.alignment = .fill
v.distribution = .fillEqually
v.spacing = 20
return v
}()
// view to hold the added views
let innerContainerView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .clear
v.clipsToBounds = true
return v
}()
// view to hold the view holding the added views (allows us to center the resulting layout)
let outerContainerView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .clear
v.clipsToBounds = true
return v
}()
// view to hold the outer container...
let borderContainerView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .white
return v
}()
// we'll be updating the .constant of these constraints
var innerWidthConstraint: NSLayoutConstraint = NSLayoutConstraint()
var innerHeightConstraint: NSLayoutConstraint = NSLayoutConstraint()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
// add the buttons to the stack view
btnsStack.addArrangedSubview(addButton)
btnsStack.addArrangedSubview(remButton)
// add inner container to outer container
outerContainerView.addSubview(innerContainerView)
// add outer container to border container
borderContainerView.addSubview(outerContainerView)
// add buttons stack to the view
view.addSubview(btnsStack)
// add border container to the view
view.addSubview(borderContainerView)
let g = view.safeAreaLayoutGuide
// initialize inner container width and height constraints
innerWidthConstraint = innerContainerView.widthAnchor.constraint(equalToConstant: 0.0)
innerHeightConstraint = innerContainerView.heightAnchor.constraint(equalToConstant: 0.0)
NSLayoutConstraint.activate([
// constrain buttons stack Top / Leading / Trailing with a little "padding"
btnsStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
btnsStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
btnsStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// buttons height to 35-pts (just for asthetics)
btnsStack.heightAnchor.constraint(equalToConstant: 35.0),
// constrain border container
// 40-pts below buttons
borderContainerView.topAnchor.constraint(equalTo: btnsStack.bottomAnchor, constant: 40.0),
// 20-pts from view bottom
borderContainerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
// 60-pts Leading and Trailing
borderContainerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
borderContainerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
// constrain outer container 5-pts on each side to match arranged view's view-to-view spacing
outerContainerView.topAnchor.constraint(equalTo: borderContainerView.topAnchor, constant: 5.0),
outerContainerView.bottomAnchor.constraint(equalTo: borderContainerView.bottomAnchor, constant: -5.0),
outerContainerView.leadingAnchor.constraint(equalTo: borderContainerView.leadingAnchor, constant: 5.0),
outerContainerView.trailingAnchor.constraint(equalTo: borderContainerView.trailingAnchor, constant: -5.0),
// activate inner container width and height constraints
innerWidthConstraint,
innerHeightConstraint,
// keep inner container centered inside outer container
innerContainerView.centerXAnchor.constraint(equalTo: outerContainerView.centerXAnchor),
innerContainerView.centerYAnchor.constraint(equalTo: outerContainerView.centerYAnchor),
])
// add actions for the Add and Delete buttons
addButton.addTarget(self, action: #selector(addTapped(_:)), for: .touchUpInside)
remButton.addTarget(self, action: #selector(remTapped(_:)), for: .touchUpInside)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
}) { [unowned self] _ in
self.arrangeViews()
}
}
@objc func addTapped(_ sender: Any?) -> Void {
// instantiate a new custom view and add it to
// the inner container view
let v = MyView()
innerContainerView.addSubview(v)
v.theLabel.text = "\(innerContainerView.subviews.count)"
// update the arrangement
arrangeViews()
}
@objc func remTapped(_ sender: Any?) -> Void {
// if inner container has at least one custom view
if let v = innerContainerView.subviews.last {
// remove it
v.removeFromSuperview()
// update the arrangement
arrangeViews()
}
}
func arrangeViews() -> Void {
// make sure there is at least 1 subview to arrange
guard innerContainerView.subviews.count > 0 else { return }
// init local vars to use
// Note: making them all CGFLoats makes it easier to use in expressions - avoids a lot of casting CGFloat(var)
var numCols: CGFloat = 0
var numRows: CGFloat = 0
var lastCols: CGFloat = 0
var lastRows: CGFloat = 0
var lastW: CGFloat = 0
var lastH: CGFloat = 0
var finalW: CGFloat = 0
var finalH: CGFloat = 0
var finalCols: CGFloat = 0
var finalRows: CGFloat = 0
var w: CGFloat = 0
var h: CGFloat = 0
// this is the frame we need to fit inside
let containerWidth: CGFloat = outerContainerView.frame.size.width
let containerHeight: CGFloat = outerContainerView.frame.size.height
// number of views to arrange
let numItems: CGFloat = CGFloat(innerContainerView.subviews.count)
// first pass, we calculate based on converting columns to rows
// start with 1 row containing all views (so, 10 views == 10 columns)
numCols = numItems
numRows = 1
// get the width and height of a single item
w = containerWidth / numCols
h = w * 8.0 / 5.0
// if the height of a single item (at 5:8 ratio) is too tall to fit
// we need to start with the height of the container
if h > containerHeight {
h = containerHeight
w = h * 5.0 / 8.0
}
// our while loop will manipulate these vars, so save each "last" value
// inside the loop
lastCols = numCols
lastRows = numRows
lastW = w
lastH = h
// while a single item height * number of rows is less than container height
// AND number of columds is greater than 1
// decrement the number of columns and re-calc
// which will add a row if needed
while h * numRows < containerHeight, numCols > 1 {
lastCols = numCols
lastRows = numRows
lastW = w
lastH = h
numCols -= 1
numRows = ceil(numItems / numCols)
w = containerWidth / numCols
h = w * 8.0 / 5.0
}
// we now have the size of a single item,
// and the number of columns and rows,
// based on columns-to-rows calculations,
// so save them for comparison
let pass1W: CGFloat = lastW
let pass1H: CGFloat = lastH
let pass1Cols: CGFloat = lastCols
let pass1Rows: CGFloat = lastRows
// second pass, we calculate based on converting rows to columns
// start with 1 column containing all views (so, 10 views == 10 rows)
numRows = numItems
numCols = 1
// get the width and height of a single item
h = containerHeight / numRows
w = h * 5.0 / 8.0
// if the width of a single item (at 5:8 ratio) is too wide to fit
// we need to start with the width of the container
if w > containerWidth {
w = containerWidth / numCols
h = w * 8.0 / 5.0
}
// our while loop will manipulate these vars, so save each "last" value
// inside the loop
lastRows = numRows
lastCols = numCols
lastH = h
lastW = w
// while a single item width * number of columns is less than container width
// AND number of rows is greater than 1
// decrement the number of rows and re-calc
// which will add a column if needed
while w * numCols < containerWidth, numRows > 1 {
lastRows = numRows
lastCols = numCols
lastH = h
lastW = w
numRows -= 1
numCols = ceil(numItems / numRows)
h = containerHeight / numRows
w = h * 5.0 / 8.0
}
// we now have the size of a single item,
// and the number of rows and columns,
// based on rows-to-columns calculations,
// so save them for comparison
let pass2W: CGFloat = lastW
let pass2H: CGFloat = lastH
let pass2Cols: CGFloat = lastCols
let pass2Rows: CGFloat = lastRows
// if second pass item size is greater than first pass item size
// use second pass results
// else
// use first pass results
if pass2H * pass2W > pass1H * pass1W {
finalW = pass2W
finalH = pass2H
finalCols = pass2Cols
finalRows = pass2Rows
} else {
finalW = pass1W
finalH = pass1H
finalCols = pass1Cols
finalRows = pass1Rows
}
// resulting width and height of the items
let innerW: CGFloat = finalW * finalCols
let innerH: CGFloat = finalH * finalRows
var x: CGFloat = 0.0
var y: CGFloat = 0.0
// loop through, doing the actual layout (setting each item's frame)
innerContainerView.subviews.forEach { v in
v.frame = CGRect(x: x, y: y, width: finalW, height: finalH)
x += finalW
if x + finalW > innerW + 1 {
x = 0.0
y += finalH
}
}
// update inner container view's width and height constraints to match
// single item width * number of columns
// single item height * number of rows
innerWidthConstraint.constant = innerW
innerHeightConstraint.constant = innerH
}
}
// simple custom view with a label in a "content container"
class MyView: UIView {
// this will hold the "content" of the custom view
// for this example, it just holds a label
let theContentView: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .cyan
return v
}()
let theLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .clear
v.textAlignment = .center
v.font = UIFont.systemFont(ofSize: 14.0)
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
self.backgroundColor = .clear
// add the label to the content view
theContentView.addSubview(theLabel)
// add the content view to self
addSubview(theContentView)
NSLayoutConstraint.activate([
// constrain the label to all 4 sides of the content view
theLabel.topAnchor.constraint(equalTo: theContentView.topAnchor, constant: 0.0),
theLabel.bottomAnchor.constraint(equalTo: theContentView.bottomAnchor, constant: 0.0),
theLabel.leadingAnchor.constraint(equalTo: theContentView.leadingAnchor, constant: 0.0),
theLabel.trailingAnchor.constraint(equalTo: theContentView.trailingAnchor, constant: 0.0),
// constrain the content view to all 4 sides of self with 5-pts "padding"
// so when two views are side-by-side, or over-under,
// the "content views" will have 10-pts spacing
theContentView.topAnchor.constraint(equalTo: topAnchor, constant: 5.0),
theContentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5.0),
theContentView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5.0),
theContentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5.0),
])
}
}
Upvotes: 1