ScatterKit
A justified image grid for SwiftUI on macOS and iOS, with an expand/contract open animation. The grid engine behind Kosmica, MIT licensed.
Click or tap a tile and it springs to the centre of the viewport while its neighbours scatter away with an exponential distance falloff and fade; click again and everything springs back. Tiles are bare Core Animation layers laid out from aspect ratios alone, and only tiles near the viewport hold pixels, so large grids stay smooth.
- macOS 14+ / iOS 17+
- Swift 6, Swift Package Manager
Installation
dependencies: [
.package(url: "https://github.com/skcript/ScatterKit", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "ScatterKit", package: "ScatterKit")]),
]
Usage
import ScatterKit
import SwiftUI
struct Gallery: View {
let photos: [URL]
let grid = ScatterGridController()
var body: some View {
ScatterGrid(items: photos.map { ScatterItem(id: $0.path, aspect: 1.5) },
content: { id, maxPixel, _ in decodeImage(at: URL(fileURLWithPath: id), maxPixel: maxPixel) },
onOpen: { print("open:", $0 ?? "none") },
controller: grid)
}
}
ScatterItem(id:aspect:revision:pixelSize:):aspectis width / height. Bumprevisionwhen a tile’s pixels change.pixelSizecaps the opened tile at one pixel per screen pixel.contentis called on a background queue with the tile id, the longest side to decode in pixels, andfull(true for the opened tile, which usually wants the original).decodeImage(at:maxPixel:)is a downsampling ImageIO decode you can use as is.- Hooks:
onOpen(id?)when a tile opens or closes;onSelect(id)on every click or tap on a tile;onActivate(id), when set, receives clicks instead of opening (callcontroller.open(id:)to open anyway);stageOverlayis a view laid over the opened tile once it lands. ScatterGridController:open(id:),close(),scrollTo(id:).ScatterGridViewis the underlyingNSView/UIViewif you’d rather host it yourself in a scroll view.
Reduce Motion replaces the springs with a short crossfade. Each on-screen tile is a VoiceOver button labelled with its id.
Configuration
ScatterConfig (pass as config:):
| Field | Default | |
|---|---|---|
item, hover | bounce 0.1 / soft | Springs for open, close, scatter and the macOS hover lift (ScatterConfig.springItem is an alternative item spring) |
hoverScale | 1.02 | Hover lift |
push, reach | 800, 1000 | Scatter offset = push · exp(−distance / reach), at a 156 pt row height |
densityThreshold | 80 | Above this many on-screen tiles the push weakens |
openDuration, closeDuration, scatterDuration, cornerDuration | 0.30, 0.30, 0.35, 0.34 s | Animation lengths |
scatterFade, closeFade | 0.13, 0.30 s | Neighbours fade out / back in |
rowHeight, gap, cornerRadius | 150, 14, 6 pt | Layout |
background | dark grey | Fill behind the tiles |
SpringMotion.bounce(_:response:) builds a spring from a bounce and response, like SwiftUI’s.
Demo app
swift run Demo [folder] shows the images in a folder (default ~/Pictures) on macOS.
Development
swift build && swift test
xcodebuild -scheme ScatterKit -destination 'generic/platform=iOS Simulator' build
License
MIT.