// // 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.. 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 } }