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 as public let scrollView = UIScrollView() in ScrollStackView.swift.
  • StackFrameLayout – Implements the actual stack layout logic (vertical or horizontal arrangement, distribution, spacing). Created as public 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:

  1. Layout side: A FrameLayout wrapper is inserted into frameLayout.frameLayouts, allowing the stack to measure and position the view.
  2. Scroll side: The actual UIView is added to scrollView.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: sizeToFit respects the axis property and isDirectionalLockEnabled, 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 contentSize is assigned to scrollView.contentSize, defining the scrollable area.
  • Frame positioning: The frameLayout is 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 UIScrollView and StackFrameLayout to provide declarative, scrollable stack layouts without manual frame calculations.
  • Dual registration occurs when calling add(_:): views are added to both the StackFrameLayout (for measurement) and the UIScrollView (for scrolling).
  • Geometry synchronization happens in layoutSubviews(), where the stack's intrinsic size is applied to scrollView.contentSize and 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, and distribution remain in sync between the public API and the internal StackFrameLayout instance.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →