[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/douglashill/DynamicButtonStack/main/DynamicButtonStack.swift [Back]  [Original]

// Douglas Hill, March 2020

import UIKit

/// A stack of buttons that dynamically adjusts the layout to fit the content in the available
/// width. The buttons are stacked either horizontally or vertically, and the image and label
/// within each button are also stacked either horizontally or vertically.
/// The height required should be found by calling sizeThatFits and passing in the width limit.
/// The height passed to sizeThatFits should be greatestFiniteMagnitude.
public class DynamicButtonStack: UIView {

    /// The spacing between adjacent edges of the buttons.
    /// This will be either horizontal or vertical spacing
    /// depending on the stacking orientation being used.
    public var spacing: CGFloat = 8 {
        didSet {
            setNeedsLayout()
        }
    }

    /// The buttons to stack.
    @objc public var buttons: [UIButton] = [] {
        willSet {
            for button in buttons {
                button.removeFromSuperview()
            }
        }
        didSet {
            didSetButtons()
        }
    }

    /// didSet is not called in an initialiser so this has been extracted.
    private func didSetButtons() {
        for button in buttons {
            button.titleLabel?.numberOfLines = 0
            button.titleLabel?.textAlignment = .center
            addSubview(button)
        }
    }

    @objc public convenience init(buttons: [UIButton]) {
        self.init(frame: .zero)

        self.buttons = buttons
        didSetButtons()
    }

    public override init(frame: CGRect) {
        super.init(frame: frame)
        sharedInit()
    }

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        sharedInit()
    }

    private func sharedInit() {
        setContentCompressionResistancePriority(.required, for: .vertical)
    }

    private func usualButtonLengthForContainerLength(_ containerLength: CGFloat) -> CGFloat {
        precondition(buttons.isEmpty == false)

        let unrounded = (containerLength - CGFloat(buttons.count - 1) * spacing) / CGFloat(buttons.count)
        return roundToPixels(unrounded, function: floor)
    }

    private func lengthForButtonAtIndex(_ index: Int, withContainerLength containerLength: CGFloat) -> CGFloat {
        let usualButtonLength = usualButtonLengthForContainerLength(containerLength)

        // In case the width doesnt divide cleanly, make the last button slightly bigger to fill the space.
        // Reasoning is the buttons are wider than the spacing so the difference will be least noticeable on the button.
        // It could be any button really. Choosing the last one is arbitrary, although it makes the implementation of frameForButtonAtIndex a bit simpler.
        if index == buttons.count - 1 {
            return containerLength - CGFloat(buttons.count - 1) * (usualButtonLength + spacing)
        } else {
            return usualButtonLength
        }
    }

    /// Returns the frame where a button should be positioned.
    /// - Parameters:
    ///   - index: The index of the button in the buttons property.
    ///   - stackingOrientation: The stacking direction of the buttons outside themselves (not of the image and title in the button).
    private func frameForButtonAtIndex(_ index: Int, stackingOrientation: UIButton.StackingOrientation) -> CGRect {
        switch stackingOrientation {
        case .horizontal:
            let effectiveIndex = isEffectiveUserInterfaceLayoutDirectionRightToLeft ? buttons.count - (index + 1) : index
            return CGRect(x: CGFloat(effectiveIndex) * (usualButtonLengthForContainerLength(bounds.width) + spacing), y: 0, width: lengthForButtonAtIndex(index, withContainerLength: bounds.width), height: bounds.height)
        case .vertical:
            return CGRect(x: 0, y: CGFloat(index) * (usualButtonLengthForContainerLength(bounds.height) + spacing), width: bounds.width, height: lengthForButtonAtIndex(index, withContainerLength: bounds.height))
        }
    }

    public override var frame: CGRect {
        didSet {
            invalidateIntrinsicContentSizeIfNeededWithOldWidth(oldValue.width)
        }
    }

    public override var bounds: CGRect {
        didSet {
            invalidateIntrinsicContentSizeIfNeededWithOldWidth(oldValue.width)
        }
    }

    private func invalidateIntrinsicContentSizeIfNeededWithOldWidth(_ oldWidth: CGFloat) {
        if bounds.width != oldWidth {
            // It doesnt work without this being async.
            DispatchQueue.main.async {
                self.invalidateIntrinsicContentSize()
            }
        }
    }

    public override var intrinsicContentSize: CGSize {
        sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude))
    }

    public override func sizeThatFits(_ availableSize: CGSize) -> CGSize {
        precondition(availableSize.height == .greatestFiniteMagnitude, "\(DynamicButtonStack.self) does not support limiting the available height.")

        if buttons.isEmpty {
            return .zero
        }

        return requiredSizeForWidth(availableSize.width)
    }

    /// Returns the smallest height for a given width.
    private func requiredSizeForWidth(_ availableWidth: CGFloat) -> CGSize {
        precondition(buttons.isEmpty == false)

        /// Layout info for when the buttons are shown side-by-side, so each button width is a fraction of the container width.
        let layoutInfoForHorizontalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in
            let buttonWidth = lengthForButtonAtIndex(index, withContainerLength: availableWidth)
            return button.layoutInfoForWidth(buttonWidth)
        }

        // (1) Try horizontal stacking of the buttons and horizontal stacking in the buttons.
        // If any button doesnt fit, move on.
        let allFitHorizontally = layoutInfoForHorizontalStacking.allSatisfy {
            switch $0.stackingOrientation {
            case .horizontal: return true
            case .vertical: return false
            }
        }
        if allFitHorizontally {
            // Use the max width rather than the sum to get a more even layout.
            let maxWidth = layoutInfoForHorizontalStacking.max(by: { $0.buttonSize.width < $1.buttonSize.width} )!.buttonSize.width
            let maxHeight = layoutInfoForHorizontalStacking.max(by: { $0.buttonSize.height < $1.buttonSize.height} )!.buttonSize.height
            return CGSize(
                width: maxWidth * CGFloat(buttons.count) + spacing * CGFloat(buttons.count - 1),
                height: maxHeight
            )
        }

        // (2) Try horizontal stacking of the buttons and vertical stacking in the buttons.
        let allFitVerticallyWithoutWrapping = layoutInfoForHorizontalStacking.allSatisfy {
            switch $0.stackingOrientation {
            case .horizontal: return true
            case .vertical: return $0.requiresWrapping == false
            }
        }
        if allFitVerticallyWithoutWrapping {
            // This is inefficiently recalculating things that may have just been calculated already. This could be addressed if performance is a problem.
            let sizes = buttons.enumerated().map { index, button -> CGSize in
                return button.sizeForVerticalStackingForWidth(nil)
            }
            let maxWidth = sizes.max(by: { $0.width < $1.width} )!.width
            let maxHeight = sizes.max(by: { $0.height < $1.height} )!.height
            return CGSize(
                width: maxWidth * CGFloat(buttons.count) + spacing * CGFloat(buttons.count - 1),
                height: maxHeight
            )
        }

        /// Layout info for when the buttons are shown top-to-bottom, so each button has the full width of the container.
        let layoutInfoForVerticalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in
            return button.layoutInfoForWidth(availableWidth)
        }

        // (3) Try vertical stacking of the buttons and horizontal stacking in the buttons.
        let allFitHorizontallyWithVerticalStacking = layoutInfoForVerticalStacking.allSatisfy {
            switch $0.stackingOrientation {
            case .horizontal: return true
            case .vertical: return false
            }
        }
        if allFitHorizontallyWithVerticalStacking {
            let maxWidth = layoutInfoForVerticalStacking.max(by: { $0.buttonSize.width < $1.buttonSize.width} )!.buttonSize.width
            let maxHeight = layoutInfoForVerticalStacking.max(by: { $0.buttonSize.height < $1.buttonSize.height} )!.buttonSize.height
            return CGSize(
                width: maxWidth,
                height: maxHeight * CGFloat(buttons.count) + spacing * CGFloat(buttons.count - 1)
            )
        }

        // (4) Go for vertical stacking of the buttons and vertical stacking in the buttons. This is the only case where the labels may use multiple lines.
        return buttons.enumerated().map { index, button -> CGSize in
            return button.sizeForVerticalStackingForWidth(availableWidth)
        }.reduce(CGSize(width: 0, height: -spacing)) { buttonSize, accumulator -> CGSize in
            CGSize(
                width: max(accumulator.width, buttonSize.width),
                height: accumulator.height + spacing + buttonSize.height
            )
        }
    }

    public override func layoutSubviews() {
        super.layoutSubviews()

        if buttons.isEmpty {
            return
        }

        // Try layouts in this order: (3) (4) (2) (1).
        // Mostly it makes sense to try from the more expanded layouts to the more compact because when given
        // more space than needed it looks best to fill it.
        // However (3) looks more balanced than (4) when there is loads of space, so try that first.

        // No attempt is made to fit within the width.
        // It is assumed that sizeThatFits was used correctly and sufficient space has been given.

        // First try stacking the buttons vertically and the image and title within each button horizontally (3).
        switch layoutVerticalHorizontal() {
        case .fits:
            return
        case .notEnoughWidth:
            // Use fully vertical stacking (4).
            layoutVerticalVertical()
            return
        case .notEnoughHeight:
            // Go on to (2) and (1).
            break
        }

        // (2)
        if layoutHorizontalVertical() {
            return
        }

        // (1)
        layoutHorizontalHorizontal()
    }

    /// This does not check if the buttons fit in the bounds height. They will be proportionally scaled to fit if needed.
    private func layoutVerticalVertical() {
        // In this mode, each button label can use multiple lines so each button will be as tall as it needs to be.

        let allInternalSizes = buttons.map { button -> UIButton.InternalSizes in
            button.internalSizesForVerticalStackingForWidth(bounds.width)
        }

        let fittingHeights = zip(buttons, allInternalSizes).map { (button, internalSizes) -> CGFloat in
            button.buttonSizeForContentSize(internalSizes.contentSize).height
        }

        let totalFittingHeightOfButtons = fittingHeights.reduce(0) { $0 + $1 }

        let availableHeightForButtons = bounds.height - spacing * CGFloat(buttons.count - 1)

        /// Used to scale up the height of each button proportionally when the space available is more or less than the space needed.
        let heightScale = availableHeightForButtons / totalFittingHeightOfButtons

        var unroundedOriginY: CGFloat = 0
        for ((button, internalSizes), fittingHeight) in zip(zip(buttons, allInternalSizes), fittingHeights) {
            let originY = roundToPixels(unroundedOriginY)
            let unroundedHeight = fittingHeight * heightScale
            let height: CGFloat

            if button === buttons.last {
                // Ensure the last one ends exactly at the bottom of the container just in case.
                height = bounds.height - originY
            } else {
                height = roundToPixels(unroundedHeight)
            }

            button.frame = CGRect(x: 0, y: originY, width: bounds.width, height: height)

            button.updateEdgeInsetsForStackingOrientation(.vertical, imageSize: internalSizes.imageSize, titleSize: internalSizes.titleSize, largestImageLength: nil, largestTitleLength: nil)

            unroundedOriginY += unroundedHeight + spacing
        }
    }

    private enum LayoutFitting {
        case notEnoughWidth
        case notEnoughHeight
        case fits
    }

    private func layoutVerticalHorizontal() -> LayoutFitting {
        /// Info for the buttons being stacked vertically.
        let layoutInfoForOuterVerticalStacking = buttons.enumerated().map { index, button -> UIButton.LayoutInfo in
            return button.layoutInfoForWidth(bounds.width)
        }

        let allFitHorizontallyWithVerticalStacking = layoutInfoForOuterVerticalStacking.allSatisfy {
            switch $0.stackingOrientation {
            case .horizontal: return true
            case .vertical: return false
            }
        }
        if allFitHorizontallyWithVerticalStacking == false {
            return .notEnoughWidth
        }

        let totalFittingHeightOfButtons = layoutInfoForOuterVerticalStacking.reduce(0) { $0 + $1.buttonSize.height }
        let availableHeightForButtons = bounds.height - spacing * CGFloat(buttons.count - 1)

        guard totalFittingHeightOfButtons  Bool {
        let allInternalSizes = buttons.enumerated().map { index, button -> UIButton.InternalSizes in
            let buttonWidth = lengthForButtonAtIndex(index, withContainerLength: bounds.width)
            return button.internalSizesForVerticalStackingForWidth(buttonWidth)
        }

        let fittingHeights = zip(buttons, allInternalSizes).map { (button, internalSizes) -> CGFloat in
            button.buttonSizeForContentSize(internalSizes.contentSize).height
        }

        let allFitVertically = fittingHeights.allSatisfy { fittingHeight -> Bool in
            fittingHeight  CGFloat {
        availableWidth - (contentEdgeInsets.left + contentEdgeInsets.right)
    }

    func layoutInfoForWidth(_ availableWidth: CGFloat) -> LayoutInfo {
        let internalSizes = internalSizesForHorizontalStacking
        let requiredContentSizeForHorizontalStacking = internalSizes.contentSize
        let imageSize = internalSizes.imageSize
        var titleSize = internalSizes.titleSize

        let availableContentWidth = availableContentWidthForAvailableWidth(availableWidth)

        let stackingOrientation: StackingOrientation
        let requiresWrapping: Bool
        let requiredContentSize: CGSize

        if requiredContentSizeForHorizontalStacking.width  CGSize {
        /// Minimum recommend touch target size.
        let minLength: CGFloat = 44
        return CGSize(
            width: max(minLength, contentSize.width + contentEdgeInsets.left + contentEdgeInsets.right),
            height: max(minLength, contentSize.height + contentEdgeInsets.top + contentEdgeInsets.bottom)
        )
    }

    /// The minimum size for the button fitting within the given width when the image and title are stacked vertically (image above title).
    func sizeForVerticalStackingForWidth(_ availableWidth: CGFloat?) -> CGSize {
        let internalSizes = internalSizesForVerticalStackingForWidth(availableWidth)
        return buttonSizeForContentSize(internalSizes.contentSize)
    }

    func updateEdgeInsetsForStackingOrientation(_ stackingOrientation: StackingOrientation, imageSize: CGSize, titleSize: CGSize, largestImageLength: CGFloat?, largestTitleLength: CGFloat?) {
        switch stackingOrientation {

        case .horizontal:
            let extraImageShift: CGFloat
            let extraTitleShift: CGFloat
            if let largestTitleLength = largestTitleLength, let largestImageLength = largestImageLength {
                extraImageShift = 0.5 * (largestTitleLength - titleSize.width)
                extraTitleShift = 0.5 * (largestTitleLength - titleSize.width - largestImageLength + imageSize.width)
            } else {
                extraImageShift = 0
                extraTitleShift = 0
            }

            imageEdgeInsets = UIEdgeInsets(view: self, top: 0, leading: -extraImageShift, bottom: 0, trailing: halfInternalSpacing + extraImageShift)
            titleEdgeInsets = UIEdgeInsets(view: self, top: 0, leading: halfInternalSpacing - extraTitleShift, bottom: 0, trailing: extraTitleShift)

        case .vertical:
            let extraImageShift: CGFloat
            let extraTitleShift: CGFloat
            if let largestTitleLength = largestTitleLength, let largestImageLength = largestImageLength {
                extraImageShift = 0.5 * (largestTitleLength - titleSize.height)
                extraTitleShift = 0.5 * (largestTitleLength - titleSize.height - largestImageLength + imageSize.height)
            } else {
                extraImageShift = 0
                extraTitleShift = 0
            }

            imageEdgeInsets = UIEdgeInsets(view: self, top: -extraImageShift, leading: 0, bottom: titleSize.height + halfInternalSpacing + extraImageShift, trailing: -titleSize.width)
            titleEdgeInsets = UIEdgeInsets(view: self, top: imageSize.height + halfInternalSpacing - extraTitleShift, leading: -imageSize.width, bottom: extraTitleShift, trailing: 0)
        }
    }
}

private extension UIEdgeInsets {
    /// Maps leading and trailing to right and left for right-to-left layout.
    @MainActor init(view: UIView, top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) {
        let left: CGFloat
        let right: CGFloat

        if view.isEffectiveUserInterfaceLayoutDirectionRightToLeft {
            left = trailing
            right = leading
        } else {
            left = leading
            right = trailing
        }

        self.init(top: top, left: left, bottom: bottom, right: right)
    }
}

private extension UIView {
    var isEffectiveUserInterfaceLayoutDirectionRightToLeft: Bool {
        switch effectiveUserInterfaceLayoutDirection {
        case .rightToLeft:
            return true
        case .leftToRight: fallthrough @unknown default:
            return false
        }
    }

    func roundToPixels(_ unrounded: CGFloat, function: (CGFloat) -> CGFloat = round) -> CGFloat {
        let scale = window?.screen.scale ?? 1
        return roundToPrecision(unrounded, precision: 1 / scale, function: function)
    }
}

private func roundToPrecision(_ unrounded: T, precision: T, function: (T) -> T) -> T where T : FloatingPoint {
    function(unrounded / precision) * precision
}

Web Proxy Viewer  |  New URL  |  Original Page