A pannable 2D canvas for SwiftUI. You give it a collection, it lays the items out on a large canvas, and it only builds the ones that are on screen.
.package(url: "https://github.com/thatswiftguy/Pannable.git", from: "1.0.0")PannableCanvas(stickers, contentSize: .fixed(width: 4000, height: 3000)) { sticker in
StickerCard(sticker: sticker)
}
.canvasLayout(.flow(horizontalSpacing: 24, verticalSpacing: 24))
.canvasContentAnchor(.center) // where the items sit on the canvas
.canvasInitialAnchor(.center) // where the viewport opensPannableCanvas(stickers) { ... } // Identifiable
PannableCanvas(photos, id: \.assetID) { ... } // key path
PannableCanvas(0..<2_000) { ... } // range.canvasLayout(.flow(horizontalSpacing: 24, verticalSpacing: 24))
.canvasLayout(.grid(columns: 8, horizontalSpacing: 12, verticalSpacing: 12)).canvasContentMargins(48)
.canvasContentMargins(EdgeInsets(top: 40, leading: 24, bottom: 40, trailing: 24))Items measure themselves. If they are all the same size, set it and nothing gets measured.
.canvasItemSize(CGSize(width: 160, height: 160))
.canvasEstimatedItemSize(CGSize(width: 120, height: 90)) // otherwise.canvasBackground(.dots(spacing: 24))
.canvasBackground(.grid(spacing: 40, color: .blue.opacity(0.25)))This works the same way as ScrollViewReader.
CanvasReader { proxy in
PannableCanvas(nodes) { NodeCard(node: $0) }
.toolbar {
Button("Reveal") { proxy.scrollTo(selection) }
Button("Origin") { proxy.scrollTo(.zero, anchor: .topLeading) }
}
}@State private var viewport = CanvasViewport()
PannableCanvas(nodes) { NodeCard(node: $0) }
.canvasViewport($viewport) // .visibleRect, .contentSize, .isMoving.canvasScrollIndicators(.hidden)
.canvasBounce(.never)
.canvasScrollDisabled(true)
.canvasDeceleration(.fast)This works like SwiftUI's Layout, except it runs on sizes instead of views. You can place items anywhere. Negative coordinates are fine, because the canvas normalizes and anchors the result for you.
struct RingCanvasLayout: CanvasLayout {
var radius: CGFloat
func place(_ items: CanvasLayoutItems, in proposal: CanvasProposal, cache: inout Void) -> CanvasLayoutResult {
let step = (2 * .pi) / CGFloat(max(items.count, 1))
return CanvasLayoutResult(frames: items.map { item in
let angle = step * CGFloat(item.index)
return CGRect(
x: radius * cos(angle) - item.size.width / 2,
y: radius * sin(angle) - item.size.height / 2,
width: item.size.width,
height: item.size.height
)
})
}
}
extension CanvasLayout where Self == RingCanvasLayout {
static func ring(radius: CGFloat) -> RingCanvasLayout { RingCanvasLayout(radius: radius) }
}.canvasLayout(.ring(radius: 600))The core is plain Swift. It takes sizes and gives back frames. It never touches UIKit, AppKit or SwiftUI, so you can test it without a simulator.
Layouts run on sizes, not views. The canvas knows where every item goes before a single view is created. That is why 10,000 items do not mean 10,000 views.
A layout does not know about the canvas. It returns a cluster of frames starting at the origin. The engine then normalizes it, mirrors it for right to left, works out the content size and anchors it. So every layout gets right to left support and anchoring without doing any work for it.
Culling uses a grid index. All the frames go into a uniform grid. Asking which items are inside a rectangle costs time based on the visible area, not on how much data there is. Every pan runs that query, and the result goes to a pool of recycled hosting controllers.
Scroll callbacks do nothing else. Measuring, allocating and re-layout all wait until the canvas stops moving. That is what keeps a fling smooth.
| Platform | Built on |
|---|---|
| iOS / iPadOS | UIScrollView, wrapped in a view controller representable because each item is a UIHostingController and needs a parent. UIScrollViewDelegate drives the culling. UIGestureRecognizerDelegate adds drag to pan with a pointer, which a scroll view does not do on its own. |
| macOS | NSScrollView with a flipped document view. Clip view bounds changes and the live scroll notifications take the place of the scroll delegate. |
| watchOS | Neither type exists there, so it is pure SwiftUI with a drag gesture and the Digital Crown. Same engine and same culling as the others. |
| What you set | What happens |
|---|---|
canvasItemSize |
Every item uses that size and nothing is measured. |
canvasEstimatedItemSize |
Items use the estimate while they are measured, then the layout settles once. |
| Neither | A default estimate is used. That is fine for hundreds of items. Set one for thousands. |
Measuring happens off screen in chunks between run loop turns, so the first frame is not blocked. Sizes are cached by item ID, so they survive reordering and inserting. Only new items get measured.
The backdrop is a tiled pattern rather than something drawn into the content view. A 6000 by 6000 content view on a 3x display would need over a gigabyte of backing store. Tiling one small image costs nothing.
Items that are off screen are not in the view hierarchy, so VoiceOver cannot swipe to them. A table view has the same problem. The three finger scroll gesture pages the canvas in all four directions instead. Hosted views also declare their accessibility order, because subviews otherwise end up in recycle order rather than data order.
- On watchOS you cannot measure a view off screen. Set
canvasItemSizeorcanvasEstimatedItemSizeif your items are different sizes. - On macOS bounce follows the system elasticity.
.neverworks, but.alwaysbehaves the same as.automatic. - The backdrop is a bitmap. It is redrawn when the appearance changes, it does not adapt on its own.
- Tracking the viewport writes state on every frame of a pan, so keep whatever depends on it small. Several observers can watch at once.
Zoom is missing. The math and the delegate hooks are already there, so adding it will not break the API. Selection, drag to move, snapping, per item coordinates and a freeform canvas are also still open.
swift testThese cover the layout engine, the spatial index, sizing, backdrop geometry and the update loop guard. No simulator needed. There is also a randomized check of the index against a brute force scan.