iOS:获取动态子视图数,以在水平和垂直方向上占据超级视图中的最大空间

时间:2019-10-30 10:23:53

标签: ios swift

我有一个具有任意子视图数量的超级视图。我正在创建的应用程序能够将子视图不断添加到该超级视图中。我希望子视图尽可能多地填充空间,如下图所示。这些子视图的纵横比为5:8

enter image description here enter image description here


我的想法是最初在垂直堆栈视图内添加一个水平堆栈视图。内部堆栈视图的数量等于一个变量,该变量设置为每行的最大卡片数(即√(numberOfViews))。当子视图的数量大于此变量时,我可以添加另一个内部堆栈视图。我必须跟踪每个内部stackview并确保它们几乎都包含相同数量的元素


我想知道是否有更简单的解决方案。同样,这种解决方案的数学运算不适用于横向定位的情况。

1 个答案:

答案 0 :(得分:1)

这听起来像是一项有趣的任务,所以我给了它一个快速的镜头。

这是一种方法...

将布局考虑为列和行。

首先将所有项目(添加的子视图)布置在一行中,以适合容器的宽度。由于项目的比例为5:8,因此我们可以计算行的高度。

  • 如果行高小于剩余的垂直空间,我们可以容纳另一行。

因此,减少列数-有效地将一项向下移动到下一行,然后重新计算。每次我们减少列数时,项目都会更宽 ...,这也会使它们变得 taller ,所以...

  • 再次检查我们是否可以容纳另一行。

减少列数,直到行太高而无法容纳为止。

下面是一个包含7个项目的示例:

enter image description here enter image description here enter image description here enter image description here enter image description here enter image description here

这时,行不再适合容器,因此我们知道3列为我们提供了最大物品尺寸。

虽然我们还没有完成...

根据物品的数量和容器的尺寸/比例,我们可能无法获得最大的尺寸。

这里是一个仅包含2个项目的示例。如果有2列,我们可以容纳另一行...但是这样做,项目变得越来越宽和越来越高,并且无法容纳在单个列中:

enter image description here enter image description here

如果我们手动布置1列x 2行,则会得到以下结果:

enter image description here

在此布局中,每个项目显然比并排的两个项目要大。

因此,在运行“行数”计算后,我们需要运行“行数”计算。相同的过程,但从单列中的所有项目开始...减少行数,直到不再适合为止:

enter image description here enter image description here enter image description here enter image description here enter image description here

4行适合,3行适合。

现在,我们将“按行划分的列”计算的最大项目大小与“按列划分的行”计算的最大项目大小进行比较,并使用较大的结果。

这里是一个完整的例子。所有代码(无@IBOutlets@IBActions),因此只需添加一个新的视图控制器并将其自定义类分配给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),

        ])
    }

}