// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import UIKit /// Like a horizontal UIStackView, except if the elements do not fit /// horizontally it "line wraps" elements to a new line, arranging within a line /// in left-to-right fashion (regardless of RTL setting). /// /// Note: I don't guarantee this will work perfectly for every imaginable use case. /// I wrote it for some specific use case and tried to make it work in the general case, /// but the combination of constraints one could apply are uncountable. If you reuse this /// and find it breaks, fix it! You can use `LineWrappingStackViewTestController` /// to quickly test and iterate. /// /// One thing this class can't do is detect size changes in subviews. If you change the size, /// call `setNeedsLayout()` on this view. public class LineWrappingStackView: UIView { // MARK: - Configuration /// Horizontal spacing between elements public var spacing: CGFloat = 8 { didSet { invalidateIntrinsicContentSize() setNeedsLayout() } } /// Vertical spacing between lines var lineSpacing: CGFloat = 8 { didSet { invalidateIntrinsicContentSize() setNeedsLayout() } } // MARK: - Adding subviews public var arrangedSubviews: [UIView] { _arrangedSubviews.lazy.map(\.0) } private var _arrangedSubviews = [(UIView, [NSLayoutConstraint])]() { didSet { invalidateIntrinsicContentSize() setNeedsLayout() } } public func addArrangedSubview(_ subview: UIView, atIndex index: Int? = nil) { addSubview(subview) let constraints = [ subview.widthAnchor.constraint(lessThanOrEqualTo: self.widthAnchor), self.bottomAnchor.constraint(greaterThanOrEqualTo: subview.bottomAnchor), ] constraints[1].priority = .required constraints.forEach({ $0.isActive = true }) if let index { _arrangedSubviews.insert((subview, constraints), at: index) } else { _arrangedSubviews.append((subview, constraints)) } } public func removeArrangedSubview(_ subview: UIView) { subview.removeFromSuperview() _arrangedSubviews.removeAll(where: { _subview, constraints in if _subview === subview { constraints.forEach({ $0.isActive = false }) return true } else { return false } }) } // MARK: - Layout override public class var requiresConstraintBasedLayout: Bool { true } override public func layoutSubviews() { super.layoutSubviews() zip(arrangedSubviews.filter({ !$0.isHidden }), arrangedSubviewRects()).forEach { $0.frame = $1 } } override public func updateConstraints() { super.updateConstraints() zip(arrangedSubviews.filter({ !$0.isHidden }), arrangedSubviewRects()).forEach { $0.frame = $1 } } override public func sizeThatFits(_ size: CGSize) -> CGSize { return CGSize( width: bounds.width, height: arrangedSubviewRects().lazy.map(\.maxY).max() ?? 0, ) } override public var intrinsicContentSize: CGSize { return sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) } private func arrangedSubviewRects() -> [CGRect] { var x: CGFloat = 0 var y: CGFloat = 0 var rowHeight: CGFloat = 0 return arrangedSubviews .lazy .filter({ !$0.isHidden }) .map { subview in // Bit of a hack to deal with a catch-22. Below, when we use systemLayoutSizeFitting, // if we use a high horizontal fitting priority we risk blowing away externally-set // constraints. If we use a low priority, we risk content that could overflow vertically // instead trying to stretch horizontally past this view's bounds. Unclear how to solve // this generally, but the thing typically capable of "overflowing" lines within itself, // UILabel, has a specific affordance for this we can take advantage of. (subview as? UILabel)?.preferredMaxLayoutWidth = bounds.width // Check what size the subview prefers to be, up to a full line width. let unconstrainedContentSize = subview.sizeThatFits(CGSize( width: bounds.width, height: CGFloat.greatestFiniteMagnitude, )) // Now apply constraints. var constrainedSize = subview.systemLayoutSizeFitting( unconstrainedContentSize, withHorizontalFittingPriority: .fittingSizeLevel, verticalFittingPriority: .required, ) // Do a second round content size calculation, now constrained by width // so individual views can overflow height. let constrainedContentSize = subview.sizeThatFits(CGSize( width: constrainedSize.width, height: CGFloat.greatestFiniteMagnitude, )) // And lastly check with constraints at the new height. constrainedSize = subview.systemLayoutSizeFitting( constrainedContentSize, withHorizontalFittingPriority: .fittingSizeLevel, verticalFittingPriority: .defaultHigh, ) var subviewWidth = min(bounds.width - x, constrainedSize.width) // If we don't fit in the line, wrap to the next line. // (Unless we are the first in this line, i.e. x=0) if x > 0, constrainedSize.width > (bounds.width - x) { x = 0 y += rowHeight + lineSpacing rowHeight = 0 subviewWidth = min(constrainedSize.width, bounds.width) } let frame = CGRect(x: x, y: y, width: subviewWidth, height: ceil(constrainedSize.height)) x += subviewWidth + spacing rowHeight = max(rowHeight, ceil(constrainedSize.height)) return frame } } }