Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/Views/ManualStackView.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import SignalServiceKit

// ManualStackView (like ManualLayoutView) uses a CATransformLayer
// by default.  CATransformLayer does not render.
//
// If you need to use properties like backgroundColor, border,
// masksToBounds, shadow, etc. you should use this subclass instead.
//
// See: https://developer.apple.com/documentation/quartzcore/catransformlayer
open class ManualStackViewWithLayer: ManualStackView {
    override open class var layerClass: AnyClass {
        CALayer.self
    }
}

// MARK: -

open class ManualStackView: ManualLayoutView {

    public typealias Measurement = ManualStackMeasurement

    private var arrangedSubviews = [UIView]()

    public var measurement: Measurement?

    public init(name: String, arrangedSubviews: [UIView] = []) {
        super.init(name: name)

        addArrangedSubviews(arrangedSubviews)
    }

    // MARK: - Config

    public var axis: NSLayoutConstraint.Axis = .horizontal
    public var alignment: UIStackView.Alignment = .center
    public var spacing: CGFloat = 0

    public typealias Config = OWSStackView.Config

    public func apply(config: Config) {
        if self.axis != config.axis {
            self.axis = config.axis
        }
        if self.alignment != config.alignment {
            self.alignment = config.alignment
        }
        if self.spacing != config.spacing {
            self.spacing = config.spacing
        }
        if self.layoutMargins != config.layoutMargins {
            self.layoutMargins = config.layoutMargins
        }
    }

    public var asConfig: Config {
        Config(
            axis: self.axis,
            alignment: self.alignment,
            spacing: self.spacing,
            layoutMargins: self.layoutMargins,
        )
    }

    // MARK: - Arrangement

    private struct ArrangementItem {
        let subview: UIView
        let frame: CGRect

        init(subview: UIView, frame: CGRect) {
            self.subview = subview
            self.frame = frame
        }

        func apply() {
            if subview.frame != frame {
                ManualLayoutView.setSubviewFrame(subview: subview, frame: frame)
            }
        }
    }

    private struct Arrangement {
        let items: [ArrangementItem]

        func apply() {
            for item in items {
                item.apply()
            }
        }
    }

    // We cache the resolved layout of the subviews.
    private var arrangement: Arrangement? {
        didSet {
            if arrangement != nil {
                invalidateIntrinsicContentSize()
                setNeedsLayout()
            }
        }
    }

    override func viewSizeDidChange() {
        invalidateArrangement()

        super.viewSizeDidChange()
    }

    public func invalidateArrangement() {
        arrangement = nil
    }

    override public func sizeThatFits(_ size: CGSize) -> CGSize {
        guard !isHidden else {
            return .zero
        }
        guard let measurement else {
            return super.sizeThatFits(size)
        }
        return measurement.measuredSize
    }

    override public func addSubview(_ view: UIView) {
        owsAssertDebug(!subviews.contains(view))
        super.addSubview(view)
        invalidateArrangement()
    }

    // NOTE: This method does _NOT_ call the superclass implementation.
    public func addArrangedSubview(_ view: UIView) {
        addSubview(view)
        owsAssertDebug(!arrangedSubviews.contains(view))

        view.translatesAutoresizingMaskIntoConstraints = false

        arrangedSubviews.append(view)
    }

    func addArrangedSubviews(_ subviews: [UIView], reverseOrder: Bool = false) {
        var subviews = subviews
        if reverseOrder {
            subviews.reverse()
        }
        for subview in subviews {
            addArrangedSubview(subview)
        }
    }

    override public func willRemoveSubview(_ view: UIView) {
        arrangedSubviews = self.arrangedSubviews.filter { view != $0 }
        super.willRemoveSubview(view)
        invalidateArrangement()
    }

    public func removeArrangedSubview(_ view: UIView) {
        view.removeFromSuperview()

        arrangedSubviews = arrangedSubviews.filter { $0 != view }
    }

    override public func layoutSubviews() {
        AssertIsOnMainThread()

        // We apply the layout blocks _after_ the arrangement.
        super.layoutSubviews(skipLayoutBlocks: true)

        guard bounds.width > 0, bounds.height > 0 else {
            for subview in subviews {
                subview.frame = .zero
            }
            return
        }

        ensureArrangement()?.apply()

        applyLayoutBlocks()
    }

    public func configure(
        config: Config,
        measurement: Measurement,
        subviews: [UIView],
    ) {
        owsAssertDebug(self.measurement == nil)

        apply(config: config)
        self.measurement = measurement
        for subview in subviews {
            addArrangedSubview(subview)
        }

        invalidateArrangement()
    }

    public func configureForReuse(config: Config, measurement: Measurement) {
        apply(config: config)
        self.measurement = measurement

        invalidateArrangement()

        layoutSubviews()
    }

    private func ensureArrangement() -> Arrangement? {
        if let arrangement {
            return arrangement
        }
        guard let measurement else {
            owsFailDebug("\(name): Missing measurement.")
            return nil
        }
        // Ignore hidden subviews.
        let arrangedSubviews = self.arrangedSubviews.filter { !$0.isHidden }
        if arrangedSubviews.count > measurement.subviewInfos.count {
            owsFailDebug("\(name): arrangedSubviews: \(arrangedSubviews.count) != subviewInfos: \(measurement.subviewInfos.count)")
        }
        let isHorizontal = axis == .horizontal
        let count = min(arrangedSubviews.count, measurement.subviewInfos.count)
        // Build the list of subviews to layout and find their layout info.
        var layoutItems = [LayoutItem]()
        for index in 0..<count {
            guard let subview = arrangedSubviews[safe: index] else {
                owsFailDebug("\(name): Missing subview.")
                break
            }
            guard let subviewInfo = measurement.subviewInfos[safe: index] else {
                owsFailDebug("\(name): Missing measuredSize.")
                break
            }
            owsAssertDebug(!subview.isHidden)
            layoutItems.append(LayoutItem(
                subview: subview,
                subviewInfo: subviewInfo,
                isHorizontal: isHorizontal,
            ))
        }
        return buildArrangement(measurement: measurement, layoutItems: layoutItems)
    }

    // An alignment enum that can be used whether the layout axis
    // is horizontal or vertical.
    private enum OffAxisAlignment: CustomStringConvertible {
        case minimum
        case center
        case maximum
        case fill

        var description: String {
            switch self {
            case .minimum:
                return ".minimum"
            case .center:
                return ".center"
            case .maximum:
                return ".maximum"
            case .fill:
                return ".fill"
            }
        }
    }

    private func buildArrangement(
        measurement: Measurement,
        layoutItems: [LayoutItem],
    ) -> Arrangement {

        guard !layoutItems.isEmpty else {
            return Arrangement(items: [])
        }

        let isRTL: Bool
        switch semanticContentAttribute {
        case .forceLeftToRight, .spatial, .playback:
            isRTL = false
        case .forceRightToLeft:
            isRTL = true
        case .unspecified:
            isRTL = CurrentAppContext().isRTL
        @unknown default:
            isRTL = CurrentAppContext().isRTL
        }

        let isHorizontal = axis == .horizontal

        // If we're horizontal *and* RTL, we want to reverse the order
        // of the layout items so they layout from RTL instead of LTR.
        var layoutItems = layoutItems
        if isRTL, isHorizontal { layoutItems = layoutItems.reversed() }

        let layoutMargins = self.layoutMargins
        let layoutSize = (bounds.size - layoutMargins.totalSize).max(.zero)

        let onAxisMaxSize: CGFloat
        let offAxisMaxSize: CGFloat
        var offAxisAlignment: OffAxisAlignment
        if isHorizontal {
            onAxisMaxSize = layoutSize.width
            offAxisMaxSize = layoutSize.height

            switch alignment {
            case .top:
                offAxisAlignment = .minimum
            case .center:
                offAxisAlignment = .center
            case .bottom:
                offAxisAlignment = .maximum
            case .fill:
                offAxisAlignment = .fill
            default:
                owsFailDebug("\(name): Invalid alignment: \(alignment.rawValue).")
                offAxisAlignment = .center
            }
        } else {
            onAxisMaxSize = layoutSize.height
            offAxisMaxSize = layoutSize.width

            switch alignment {
            case .leading:
                offAxisAlignment = isRTL ? .maximum : .minimum
            case .center:
                offAxisAlignment = .center
            case .trailing:
                offAxisAlignment = isRTL ? .minimum : .maximum
            case .fill:
                offAxisAlignment = .fill
            default:
                owsFailDebug("Invalid alignment: \(alignment.rawValue).")
                offAxisAlignment = .center
            }
        }

        // Initialize onAxisLocation.
        var onAxisSizeTotal: CGFloat = 0
        for (index, layoutItem) in layoutItems.enumerated() {
            if index > 0 {
                onAxisSizeTotal += spacing
            }
            layoutItem.onAxisSize = layoutItem.onAxisMeasuredSize
            onAxisSizeTotal += layoutItem.onAxisMeasuredSize
        }

        // Handle underflow and overflow.
        //
        // If a stack's contents do not fit within the stack's bounds, they "overflow".
        // If a stack's contents are smaller than the stack's bounds, they "underflow".
        let fuzzyTolerance: CGFloat = 0.001
        if abs(onAxisSizeTotal - onAxisMaxSize) < fuzzyTolerance {
            // Exact match; no adjustments necessary.
        } else if onAxisSizeTotal < onAxisMaxSize {
            // Underflow case
            //
            // Underflow is expected; a stack view is often larger than
            // the minimum size of its contents.  The stack view will
            // expand the layout of its contents to take advantage of
            // the extra space.
            let underflow = onAxisMaxSize - onAxisSizeTotal

            // TODO: We could weight re-distribution by contentHuggingPriority.
            var underflowLayoutItems = layoutItems.filter {
                $0.subviewInfo.canExpandOnAxis(isHorizontalLayout: isHorizontal)
            }
            if underflowLayoutItems.isEmpty {
                owsFailDebug("\(name): No underflowLayoutItems.")
                underflowLayoutItems = layoutItems
            }

            let adjustment = underflow / CGFloat(underflowLayoutItems.count)
            for layoutItem in underflowLayoutItems {
                layoutItem.onAxisSize = max(0, layoutItem.onAxisSize + adjustment)
            }
        } else if onAxisSizeTotal > onAxisMaxSize {
            // Overflow case
            //
            // Overflow should be rare, at least in the conversation view cells.
            // It is expected in some cases, e.g. when animating an orientation
            // change when the new layout hasn't landed yet.
            let overflow = onAxisSizeTotal - onAxisMaxSize

            // TODO: We could weight re-distribution by compressionResistence.
            var overflowLayoutItems = layoutItems.filter {
                $0.subviewInfo.canCompressOnAxis(isHorizontalLayout: isHorizontal)
            }
            if overflowLayoutItems.isEmpty {
                overflowLayoutItems = layoutItems
            }

            let adjustment = overflow / CGFloat(overflowLayoutItems.count)
            for layoutItem in overflowLayoutItems {
                layoutItem.onAxisSize = max(0, layoutItem.onAxisSize - adjustment)
            }
        }

        // Determine onAxisLocation.
        var onAxisLocation: CGFloat = 0
        for layoutItem in layoutItems {
            layoutItem.onAxisLocation = onAxisLocation
            onAxisLocation += layoutItem.onAxisSize + spacing
        }

        // Determine offAxisSize and offAxisLocation.
        for layoutItem in layoutItems {
            var offAxisSize: CGFloat = min(layoutItem.offAxisMeasuredSize, offAxisMaxSize)
            if
                offAxisAlignment == .fill,
                layoutItem.subviewInfo.canExpandOffAxis(isHorizontalLayout: isHorizontal)
            {
                offAxisSize = offAxisMaxSize
            }
            layoutItem.offAxisSize = offAxisSize

            switch offAxisAlignment {
            case .minimum:
                layoutItem.offAxisLocation = 0
            case .maximum:
                layoutItem.offAxisLocation = offAxisMaxSize - offAxisSize
            case .center, .fill:
                layoutItem.offAxisLocation = (offAxisMaxSize - offAxisSize) * 0.5
            }
        }

        // Apply layoutMargins and locationOffset.
        for layoutItem in layoutItems {
            layoutItem.frame.x += layoutMargins.left + layoutItem.subviewInfo.locationOffset.x
            layoutItem.frame.y += layoutMargins.top + layoutItem.subviewInfo.locationOffset.y
        }

        let arrangementItems = layoutItems.map { $0.asArrangementItem }
        return Arrangement(items: arrangementItems)
    }

    private class LayoutItem {
        let subview: UIView
        let subviewInfo: ManualStackSubviewInfo
        let isHorizontal: Bool
        var frame: CGRect = .zero

        var measuredSize: CGSize { subviewInfo.measuredSize }

        var onAxisMeasuredSize: CGFloat {
            if isHorizontal {
                return measuredSize.width
            } else {
                return measuredSize.height
            }
        }

        var offAxisMeasuredSize: CGFloat {
            if isHorizontal {
                return measuredSize.height
            } else {
                return measuredSize.width
            }
        }

        var onAxisSize: CGFloat {
            get {
                if isHorizontal {
                    return frame.width
                } else {
                    return frame.height
                }
            }
            set {
                if isHorizontal {
                    frame.width = newValue
                } else {
                    frame.height = newValue
                }
            }
        }

        var offAxisSize: CGFloat {
            get {
                if isHorizontal {
                    return frame.height
                } else {
                    return frame.width
                }
            }
            set {
                if isHorizontal {
                    frame.height = newValue
                } else {
                    frame.width = newValue
                }
            }
        }

        var onAxisLocation: CGFloat {
            get {
                if isHorizontal {
                    return frame.x
                } else {
                    return frame.y
                }
            }
            set {
                if isHorizontal {
                    frame.x = newValue
                } else {
                    frame.y = newValue
                }
            }
        }

        var offAxisLocation: CGFloat {
            get {
                if isHorizontal {
                    return frame.y
                } else {
                    return frame.x
                }
            }
            set {
                if isHorizontal {
                    frame.y = newValue
                } else {
                    frame.x = newValue
                }
            }
        }

        init(
            subview: UIView,
            subviewInfo: ManualStackSubviewInfo,
            isHorizontal: Bool,
        ) {

            self.subview = subview
            self.subviewInfo = subviewInfo
            self.isHorizontal = isHorizontal
        }

        var asArrangementItem: ArrangementItem {
            ArrangementItem(subview: subview, frame: frame)
        }
    }

    public static func measure(
        config: Config,
        subviewInfos: [ManualStackSubviewInfo],
        verboseLogging: Bool = false,
    ) -> Measurement {

        let subviewSizes = subviewInfos.map { $0.measuredSize.max(.zero) }

        let spacingCount = max(0, subviewSizes.count - 1)

        var size = CGSize.zero
        switch config.axis {
        case .horizontal:
            size.width = subviewSizes.map { $0.width }.reduce(0, +)
            size.height = subviewSizes.map { $0.height }.reduce(0, max)
            size.width += CGFloat(spacingCount) * config.spacing
        case .vertical:
            size.width = subviewSizes.map { $0.width }.reduce(0, max)
            size.height = subviewSizes.map { $0.height }.reduce(0, +)
            size.height += CGFloat(spacingCount) * config.spacing
        @unknown default:
            owsFailDebug("Unknown axis: \(config.axis)")
        }

        size.width += config.layoutMargins.totalWidth
        size.height += config.layoutMargins.totalHeight
        size = size.ceil

        return Measurement(measuredSize: size, subviewInfos: subviewInfos)
    }

    override open func reset() {
        AssertIsOnMainThread()

        super.reset()

        alignment = .fill
        axis = .vertical
        spacing = 0

        self.measurement = nil
    }
}

// MARK: -

//// TODO: Can this be moved to UIView+OWS.swift?
private extension CGRect {

    var width: CGFloat {
        get {
            size.width
        }
        set {
            size.width = newValue
        }
    }

    var height: CGFloat {
        get {
            size.height
        }
        set {
            size.height = newValue
        }
    }
}

// MARK: -

// Analogous to UIView.compressionResistence and .contentHugging.
//
// If a stack's contents do not fit within the stack's bounds, they "overflow".
// If a stack's contents are smaller than the stack's bounds, they "underflow".
public enum ManualFlowBehavior {
    case fixed
    case canExpand
    case canCompress
    case canExpandAndCompress

    var canExpand: Bool {
        switch self {
        case .fixed, .canCompress:
            return false
        case .canExpand, .canExpandAndCompress:
            return true
        }
    }

    var canCompress: Bool {
        switch self {
        case .fixed, .canExpand:
            return false
        case .canCompress, .canExpandAndCompress:
            return true
        }
    }
}

// MARK: -

public struct ManualStackSubviewInfo: Equatable {
    let measuredSize: CGSize

    let horizontalFlowBehavior: ManualFlowBehavior
    let verticalFlowBehavior: ManualFlowBehavior

    let locationOffset: CGPoint

    public init(
        measuredSize: CGSize,
        horizontalFlowBehavior: ManualFlowBehavior,
        verticalFlowBehavior: ManualFlowBehavior,
        locationOffset: CGPoint = .zero,
    ) {
        self.measuredSize = measuredSize
        self.horizontalFlowBehavior = horizontalFlowBehavior
        self.verticalFlowBehavior = verticalFlowBehavior
        self.locationOffset = locationOffset
    }

    public init(
        measuredSize: CGSize,
        hasFixedWidth: Bool = false,
        hasFixedHeight: Bool = false,
        locationOffset: CGPoint = .zero,
    ) {
        self.measuredSize = measuredSize
        self.horizontalFlowBehavior = hasFixedWidth ? .fixed : .canExpandAndCompress
        self.verticalFlowBehavior = hasFixedHeight ? .fixed : .canExpandAndCompress
        self.locationOffset = locationOffset
    }

    public init(
        measuredSize: CGSize,
        hasFixedSize: Bool,
        locationOffset: CGPoint = .zero,
    ) {
        self.measuredSize = measuredSize
        self.horizontalFlowBehavior = hasFixedSize ? .fixed : .canExpandAndCompress
        self.verticalFlowBehavior = hasFixedSize ? .fixed : .canExpandAndCompress
        self.locationOffset = locationOffset
    }

    public init(measuredSize: CGSize, subview: UIView) {
        self.measuredSize = measuredSize

        let hasFixedWidth = subview.contentHuggingPriority(for: .horizontal) != .defaultHigh
        let hasFixedHeight = subview.contentHuggingPriority(for: .vertical) != .defaultHigh
        self.horizontalFlowBehavior = hasFixedWidth ? .fixed : .canExpandAndCompress
        self.verticalFlowBehavior = hasFixedHeight ? .fixed : .canExpandAndCompress

        self.locationOffset = .zero
    }

    private static func setSubviewFrame(subview: UIView, frame: CGRect) {
        guard subview.frame != frame else {
            return
        }
        subview.frame = frame
    }

    public static var empty: ManualStackSubviewInfo {
        ManualStackSubviewInfo(measuredSize: .zero)
    }

    func canExpandOnAxis(isHorizontalLayout: Bool) -> Bool {
        (isHorizontalLayout ? horizontalFlowBehavior : verticalFlowBehavior).canExpand
    }

    func canCompressOnAxis(isHorizontalLayout: Bool) -> Bool {
        (isHorizontalLayout ? horizontalFlowBehavior : verticalFlowBehavior).canCompress
    }

    func canExpandOffAxis(isHorizontalLayout: Bool) -> Bool {
        (isHorizontalLayout ? verticalFlowBehavior : horizontalFlowBehavior).canExpand
    }

    func canCompressOffAxis(isHorizontalLayout: Bool) -> Bool {
        (isHorizontalLayout ? verticalFlowBehavior : horizontalFlowBehavior).canCompress
    }
}

// MARK: -

public extension CGSize {
    var asManualSubviewInfo: ManualStackSubviewInfo {
        ManualStackSubviewInfo(measuredSize: self)
    }

    func asManualSubviewInfo(
        hasFixedWidth: Bool = false,
        hasFixedHeight: Bool = false,
        locationOffset: CGPoint = .zero,
    ) -> ManualStackSubviewInfo {
        ManualStackSubviewInfo(
            measuredSize: self,
            hasFixedWidth: hasFixedWidth,
            hasFixedHeight: hasFixedHeight,
            locationOffset: locationOffset,
        )
    }

    func asManualSubviewInfo(
        hasFixedSize: Bool,
        locationOffset: CGPoint = .zero,
    ) -> ManualStackSubviewInfo {
        ManualStackSubviewInfo(
            measuredSize: self,
            hasFixedSize: hasFixedSize,
            locationOffset: locationOffset,
        )
    }

    func asManualSubviewInfo(
        horizontalFlowBehavior: ManualFlowBehavior,
        verticalFlowBehavior: ManualFlowBehavior,
        locationOffset: CGPoint = .zero,
    ) -> ManualStackSubviewInfo {
        ManualStackSubviewInfo(
            measuredSize: self,
            horizontalFlowBehavior: horizontalFlowBehavior,
            verticalFlowBehavior: verticalFlowBehavior,
            locationOffset: locationOffset,
        )
    }
}

// MARK: -

public struct ManualStackMeasurement: Equatable {
    public let measuredSize: CGSize

    fileprivate let subviewInfos: [ManualStackSubviewInfo]

    init(measuredSize: CGSize, subviewInfos: [ManualStackSubviewInfo]) {
        self.measuredSize = measuredSize
        self.subviewInfos = subviewInfos
    }

    fileprivate var subviewMeasuredSizes: [CGSize] {
        subviewInfos.map { $0.measuredSize }
    }

    public static func build(measuredSize: CGSize) -> ManualStackMeasurement {
        ManualStackMeasurement(measuredSize: measuredSize, subviewInfos: [])
    }
}

// MARK: -

public extension ManualStackView {
    @discardableResult
    func configure(
        config: Config,
        subviews: [UIView],
        subviewInfos: [ManualStackSubviewInfo],
    ) -> Measurement {
        let measurement = ManualStackView.measure(config: config, subviewInfos: subviewInfos)
        self.configure(config: config, measurement: measurement, subviews: subviews)
        return measurement
    }
}