# How ScrollStackView Combines UIScrollView with StackFrameLayout for Scrollable Content

> Discover how ScrollStackView merges UIScrollView and StackFrameLayout for dynamic, scrollable content. Learn to create auto-sizing layouts effortlessly.

- Repository: [Nam Kennic/framelayoutkit](https://github.com/kennic/framelayoutkit)
- Tags: deep-dive
- Published: 2026-03-05

---

**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`](https://github.com/kennic/framelayoutkit/blob/main/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`](https://github.com/kennic/framelayoutkit/blob/main/ScrollStackView.swift), the class establishes a specific view hierarchy that distinguishes between the container, the scrolling surface, and the layout engine:

```swift
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:

```swift
@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`](https://github.com/kennic/framelayoutkit/blob/main/ScrollStackView.swift) is where the synchronization between the stack's intrinsic size and the scroll view's content size occurs:

```swift
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:

```swift
// 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`:

```swift
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.