Skip to content

About

A custom layout protocol for SwiftUI, powered by a lazy scroll view.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Repository files navigation

Masonry Heavy grid Grid Spans Flow
Demo

Lazy Custom Layout

A lazy, anchor-based scroll engine for SwiftUI — stream any custom layout (masonry, flow, grid, table...) without ever measuring the whole collection up front

SPM License: MIT

Usage

The simplest way to use the library is one of the built-in LazySmth views, e.g. a masonry grid:

import LazyCustomLayout

LazyMasonry(0..<300, lanes: 2, spacing: 8, estimatedItemLength: 160) { index in
    Tile(index: index)
}

estimatedItemLength is just a guess used to decide how many items to measure before a real size comes in — doesn't need to be accurate, only roughly right.

Scrolling engine

LazyAnchoredScroll/AnchoredScrollView don't use UIScrollView. A UIScrollView (and LazyVStack/LazyVGrid on top of it) tracks an absolute contentOffset against a total contentSize — so a lazy container has to estimate that total size up front and keep correcting it as real item sizes stream in. That's the jump you see when content loads above the visible area, and why fully custom column widths aren't supported.

Instead, we track an anchor: (item id, offset within that item). Nothing on screen is positioned relative to the start of the content, only relative to its own anchor — so there's never a total size to know, estimate, or correct. That's what makes true laziness possible for an arbitrary custom layout, not just the handful UIKit/SwiftUI ship built in. Physics and gestures (pan, deceleration, rubber-banding) are reimplemented on top of this model rather than borrowed from UIScrollView.

For a simple table, this is just LazyStack:

LazyStack(0..<2000, spacing: 1, estimatedItemLength: 60, maxLoadedItems: 30) { index in
    TableRow(index: index)
}

maxLoadedItems caps how many rows are ever instantiated at once, regardless of buffer distance — useful for heavy rows.

Sizing

Every LazySmth view (and LazyAnchoredScroll directly) sizes itself along its scroll axis via .lazyLayoutSizing(_:):

LazyFlow(tags, spacing: 6, lineSpacing: 6, estimatedItemLength: 60) { tag in Chip(tag) }
    .lazyLayoutSizing(.fitToContent)
  • .scroll (default) — hugs its content up to the space it's offered, then scrolls once content overflows. Grows to the full content length if the parent proposes no length on this axis at all.
  • .fitToContent — eager and non-scrolling: every item is measured and placed in one pass, and the view hugs the full result. Only reach for this with content genuinely small enough to render in full, e.g. a handful of tags in a card — not a feed.
  • .scrollFillingSpace — always fills whatever space it's offered, scrollable or not. The behavior every lazy layout had before .lazyLayoutSizing(_:) existed; reach for this for a full-screen feed.

Available modifiers

lazyLayoutSizing - How a lazy view sizes itself along its scroll axis: .scroll (default), .fitToContent or .scrollFillingSpace, see above. Applies to every LazySmth view beneath it
cellReuse - Whether cells are recycled through a stable pool of SwiftUI identities as the window moves, default is true, see below
gridSpan - Applied to an item inside LazyGrid/LazyDataGrid: how many columns and rows it covers, default is 1 by 1, e.g. .gridSpan(column: 2, row: 1)
measuringLength / measuringSize - Reports the view's length along an axis (or its full size) whenever it changes. Useful when building your own container on top of the scroll engine

Cell reuse

Like UITableView recycles its cells, LazyAnchoredScroll keeps a stable pool of SwiftUI identities and rebinds them to different items as they enter and leave the window. Crossing the buffer boundary during a scroll is then a content update to an already-mounted cell instead of an insert/remove — which matters for cells with many subviews, where building and tearing down that many nodes per item is what makes scrolling stutter. Reuse is on by default.

The catch is the same as with UITableViewCell: a cell's local @State survives being rebound to another item. Derive per-item state from the item itself, or reset it with .onChange(of:):

struct Tile: View {
    var item: Item
    @State private var isLiked = false

    var body: some View {
        TileContent(item: item, isLiked: $isLiked)
            .onChange(of: item.id) { isLiked = false }
    }
}

If a cell genuinely needs a fresh @State per item, opt out for everything beneath the modifier:

LazyGrid(columns: 3, spacing: 12, estimatedRowLength: 130) {
    ForEach(items) { item in Tile(item) }
}
.cellReuse(false)

The StreamingLayout protocol

Shaped like SwiftUI's own Layout, propose → measure → place, but asked about one item at a time instead of the whole collection — so a custom layout only ever needs to know about a small window around the anchor, never every item.

struct PagingLayout: StreamingLayout {
    func proposedSize(context: StreamingLayoutContext, cache: inout Void) -> ProposedViewSize {
        ProposedViewSize(context.viewportSize)
    }

    func place(size: CGSize, context: StreamingLayoutContext, cache: inout Void) -> CGRect {
        let origin = context.direction == .forward
            ? (context.neighborFrame?.maxY ?? 0)
            : (context.neighborFrame?.minY ?? 0) - size.height
        return CGRect(x: 0, y: origin, width: size.width, height: size.height)
    }
}

place gets the already-placed neighbor's frame (context.neighborFrame, nil for the anchor item itself) and returns this item's frame right next to it — everything else (identifying the window, caching sizes, translating into on-screen offsets) is handled for you by LazyAnchoredScroll, which any StreamingLayout plugs straight into:

LazyAnchoredScroll(data, layout: PagingLayout(), estimatedItemLength: ...) { item in ItemView(item) }

Built-in layouts

Each one is a folder with (at least) two files: the SmthLayout itself — the StreamingLayout conformance — and LazySmth, a one-line wrapper around LazyAnchoredScroll(layout: SmthLayout(...)).

  • StackLayout / LazyStack — one column/row, each item sized to itself. Simplest layout, good for tables and lists.
  • MasonryLayout / LazyMasonry — N equal-width lanes, each item going into whichever lane is currently shortest. Pinterest-style grid:
    LazyMasonry(items, lanes: 2, spacing: 8, estimatedItemLength: 160) { item in TileView(item) }
  • FlowLayout / LazyFlow — variable-width items wrap left-to-right once the current line runs out of room. Tag cloud:
    LazyFlow(words, spacing: 8, lineSpacing: 10, estimatedItemLength: 44) { word in Chip(word) }
  • GridLayout / LazyGrid — CSS-Grid-style auto-placement over fixed (.px) or proportional (.fr) tracks, with multi-cell spans. LazyGrid takes columns:/rows: to scroll one axis, or both to get a fixed, non-scrolling grid:
    LazyGrid(columns: 3, spacing: 12, estimatedRowLength: 130) {
        ForEach(0..<300) { index in GridTile(index) }
    }
  • AnchoredScrollView — not a StreamingLayout, so no SmthLayout/LazySmth pair: the same scroll engine applied to one eagerly-rendered piece of content with nothing to window. Use this when there's no collection, just AnchoredScrollView { content }.

Example project

LazyCustomLayoutExample/LazyCustomLayoutExample.xcodeproj has one screen per layout above, plus a side-by-side comparison against ScrollView.

Installation

dependencies: [
    .package(url: "https://github.com/exyte/LazyCustomLayout.git")
]

Requirements

  • iOS 18.0+

Our other open source SwiftUI libraries

AnchoredPopup - Popup that grows "out" of a trigger view, anchored to it
PopupView - Toasts and popups library
Grid - The most powerful Grid container
AnimatedTabBar - A tabbar with a number of preset animations
ScalingHeaderScrollView - A scroll view with a sticky header which shrinks as you scroll
MediaPicker - Customizable media picker
Chat - Chat UI framework with fully customizable message cells, input view, and a built-in media picker
CalendarView - Calendar view with fully customizable month/day cells
AnimatedGradient - Animated linear gradient
ConcentricOnboarding - Animated onboarding flow
FloatingButton - Floating button menu
ActivityIndicatorView - A number of animated loading indicators
ProgressIndicatorView - A number of animated progress indicators
FlagAndCountryCode - Phone codes and flags for every country

About

A custom layout protocol for SwiftUI, powered by a lazy scroll view.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages