How ScrollStackView Combines UIScrollView with StackFrameLayout for Scrollable Content
ScrollStackView acts as a bridging container that embeds a StackFrameLayout inside a UIScrollView, automatically calculating the stack's intrinsic size and applying it to the scroll view's contentSize to create auto-sizing, scrollable stack layouts.
FrameLayoutKit provides a declarative layout engine for iOS that simplifies complex view arrangements. The ScrollStackView class solves the common challenge of making stack-based content scrollable by mediating between a UIScrollView instance and a StackFrameLayout instance, ensuring the scrollable area always matches the combined size of the stacked children.
Core Architecture: Three Collaborating Components
ScrollStackView orchestrates three distinct objects to achieve scrollable stack behavior:
UIScrollView– Handles native scrolling mechanics including content offset, bounce effects, scroll indicators, and directional lock. Instantiated aspublic let scrollView = UIScrollView()inScrollStackView.swift.StackFrameLayout– Implements the actual stack layout logic (vertical or horizontal arrangement, distribution, spacing). Created aspublic let frameLayout = StackFrameLayout(axis: .vertical, distribution: .top).ScrollStackView(the container) – Exposes a unified API for adding views and synchronizes the geometry between the scroll view and the stack layout.
Initialization and View Hierarchy Wiring
During initialization in ScrollStackView.swift, the class establishes a specific view hierarchy that distinguishes between the container, the scrolling surface, and the layout engine:
public required init() {
super.init(frame: .zero)
// Configure UIScrollView native behavior
scrollView.bounces = true
scrollView.isDirectionalLockEnabled = true
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.clipsToBounds = false
scrollView.delaysContentTouches = false
#if os(iOS)
if #available(iOS 11.0, *) { scrollView.contentInsetAdjustmentBehavior = .never }
#endif
// Configure the stack layout engine
frameLayout.spacing = 0.0
frameLayout.isIntrinsicSizeEnabled = true
frameLayout.shouldCacheSize = false
// Wire the hierarchy: stack lives inside the scroll view
scrollView.addSubview(frameLayout)
addSubview(scrollView) // scroll view fills the entire ScrollStackView
}
The hierarchy is critical: frameLayout is a subview of scrollView, not the ScrollStackView itself. This allows the stack to grow beyond the visible bounds while the scroll view manages the viewport and scrolling mechanics.
Adding Views: The Dual Registration Pattern
When you add a view using add(_:), ScrollStackView performs a dual registration to maintain consistency between the layout engine and the scrollable surface:
@discardableResult
open func add(_ view: UIView?) -> FrameLayout {
let layout = frameLayout.add(view) // 1. Register in StackFrameLayout
if let view { scrollView.addSubview(view) } // 2. Add to UIScrollView for scrolling
setNeedsLayout()
return layout
}
This two-step process ensures that:
- Layout side: A
FrameLayoutwrapper is inserted intoframeLayout.frameLayouts, allowing the stack to measure and position the view. - Scroll side: The actual
UIViewis added toscrollView.subviews, making it subject to the scroll view's clipping and transform operations.
The views and _views properties provide array-based management, while updateLayout() synchronizes the two structures when the view array is modified directly.
The Layout Pass: Synchronizing Geometry
The layoutSubviews() method in ScrollStackView.swift is where the synchronization between the stack's intrinsic size and the scroll view's content size occurs:
override open func layoutSubviews() {
if !isEnabled { return }
willLayoutSubviewsBlock?(self)
super.layoutSubviews()
// 1. Determine target size respecting axis and directional lock
let viewSize = bounds.size
let sizeToFit = !isDirectionalLockEnabled
? contentFitSize
: (axis == .horizontal
? CGSize(width: contentFitSize.width, height: viewSize.height)
: CGSize(width: viewSize.width, height: contentFitSize.height))
// 2. Query StackFrameLayout for combined intrinsic size
let contentSize = frameLayout.sizeThatFits(sizeToFit, intrinsic: true)
// 3. Apply geometry to UIScrollView
scrollView.contentSize = contentSize
scrollView.frame = bounds
// 4. Build contentFrame for stack positioning
var contentFrame = bounds
if axis == .horizontal {
contentFrame.size.width = max(viewSize.width, contentSize.width)
contentFrame.size.height = isDirectionalLockEnabled
? min(viewSize.height, contentSize.height)
: max(viewSize.height, contentSize.height)
} else {
contentFrame.size.height = max(viewSize.height, contentSize.height)
contentFrame.size.width = isDirectionalLockEnabled
? min(viewSize.width, contentSize.width)
: max(viewSize.width, contentSize.width)
}
// 5. Position the stack inside the scroll view
frameLayout.frame = contentFrame
didLayoutSubviewsBlock?(self)
}
The key steps are:
- Size calculation:
sizeToFitrespects theaxisproperty andisDirectionalLockEnabled, ensuring the stack doesn't expand in the locked dimension. - Intrinsic sizing:
frameLayout.sizeThatFits(_:intrinsic:)calculates the total size required by all children, accounting for spacing, insets, and distribution. - Content size assignment: The calculated
contentSizeis assigned toscrollView.contentSize, defining the scrollable area. - Frame positioning: The
frameLayoutis sized to either match or exceed the visible bounds depending on the scroll direction and lock settings, then positioned within the scroll view.
Configuration and Property Forwarding
ScrollStackView exposes convenience properties that directly forward to the underlying StackFrameLayout, ensuring changes trigger a relayout:
// Example: spacing property (lines 24-30)
open var spacing: CGFloat {
get { return frameLayout.spacing }
set {
frameLayout.spacing = newValue
setNeedsLayout()
}
}
// Example: axis property (lines 56-62)
open var axis: Axis {
get { return frameLayout.axis }
set {
frameLayout.axis = newValue
setNeedsLayout()
}
}
Properties like distribution, edgeInsets, minSize, maxSize, isSkeletonMode, and debug all follow this pattern. Modifying any of these calls setNeedsLayout() on the container, which triggers the layoutSubviews() sequence described above.
Complete Implementation Example
The following example creates a vertically scrollable list of 20 labels using ScrollStackView:
import FrameLayoutKit
// Create and configure the scrollable stack
let list = ScrollStackView()
list.axis = .vertical
list.spacing = 12
list.edgeInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
// Add content
for i in 0..<20 {
let label = UILabel()
label.text = "Row #\(i + 1)"
label.font = .systemFont(ofSize: 16, weight: .medium)
label.backgroundColor = .systemGray5
label.layer.cornerRadius = 8
label.clipsToBounds = true
label.textAlignment = .center
label.heightAnchor.constraint(equalToConstant: 44).isActive = true
list.add(label) // Registers in stack and adds to scroll view
}
// Add to view controller
class DemoVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(list)
list.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
list.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
list.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
list.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
list.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
}
}
When this runs, ScrollStackView automatically calculates the total height of the 20 rows (plus spacing and insets), sets scrollView.contentSize accordingly, and allows vertical scrolling through the content.
Summary
- ScrollStackView wraps a
UIScrollViewandStackFrameLayoutto provide declarative, scrollable stack layouts without manual frame calculations. - Dual registration occurs when calling
add(_:): views are added to both theStackFrameLayout(for measurement) and theUIScrollView(for scrolling). - Geometry synchronization happens in
layoutSubviews(), where the stack's intrinsic size is applied toscrollView.contentSizeand the stack frame is sized to accommodate scrolling. - Directional lock support allows constraining the stack expansion to a single axis, preventing unwanted growth in the cross-axis dimension.
- Property forwarding ensures that stack attributes like
spacing,axis, anddistributionremain in sync between the public API and the internalStackFrameLayoutinstance.
Frequently Asked Questions
How does ScrollStackView determine the content size for scrolling?
ScrollStackView calculates the content size in layoutSubviews() by calling frameLayout.sizeThatFits(sizeToFit, intrinsic: true), which measures all child FrameLayout instances and returns the combined intrinsic size including spacing and insets. This value is directly assigned to scrollView.contentSize, ensuring the scrollable area matches the total size of the stacked content.
What happens when I call add(_:) on a ScrollStackView?
The add(_:) method performs two operations: it inserts a FrameLayout entry into the internal frameLayout to handle measurement and positioning, and it adds the actual UIView to scrollView as a subview. This dual registration ensures the view participates in both the stack's layout calculations and the scroll view's clipping and transform operations.
How do I enable horizontal scrolling with ScrollStackView?
Set the axis property to .horizontal. When axis is horizontal, the layoutSubviews() method calculates contentFrame.size.width using max(viewSize.width, contentSize.width), allowing the stack to extend beyond the visible width. The scroll view's contentSize is updated to reflect the total width, enabling horizontal scrolling while optionally respecting isDirectionalLockEnabled to constrain height.
What is the purpose of directional lock in ScrollStackView?
isDirectionalLockEnabled (defaulting to true via the underlying scroll view configuration) prevents the stack from expanding in the cross-axis direction. When enabled, the sizeToFit calculation uses min() for the cross-axis dimension (e.g., height in horizontal mode), ensuring the content does not scroll diagonally and stays constrained to the visible bounds in the locked dimension.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →