Scattered Photo Board
Photos scattered at slight angles on a board that can be dragged around and brought to the front on touch.

- Images
- iOS 18.0+
- advanced
- Origin
- Original work
- Found by
- photo board · polaroid · scattered · collage · drag · bring to front · moodboard · tablero de fotos · polaroid · dispersas · collage · arrastrar · traer al frente
Description
Section titled “Description”Photos scattered at slight angles on a board. Touching one brings it to the front, and it can be dragged anywhere on the board.
VoiceOver reads each caption and offers an action to bring the photo to the front. With Reduce Motion on, a card does not lift while dragged.
struct ScatteredPhotoBoardDemo: View { private let photos = [ demoPhoto("Lisbon", [.orange, .pink], "sun.max.fill"), demoPhoto("Oslo", [.cyan, .blue], "snowflake"), demoPhoto("Kyoto", [.pink, .purple], "leaf.fill"), demoPhoto("Quito", [.green, .teal], "mountain.2.fill"), demoPhoto("Cairo", [.yellow, .orange], "sparkles"), ]
var body: some View { ScatteredPhotoBoard(photos: photos) .background { LinearGradient( colors: [Color(red: 0.72, green: 0.55, blue: 0.38), Color(red: 0.55, green: 0.39, blue: 0.25)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() } }}
private func demoPhoto(_ caption: String, _ colors: [Color], _ symbol: String) -> ScatteredPhotoBoardPhoto { let size = CGSize(width: 400, height: 400) let image = Image(size: size, label: Text(caption)) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: 400, y: 400)) ) var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.9)) context.draw(icon, in: CGRect(x: 120, y: 120, width: 160, height: 160)) } return ScatteredPhotoBoardPhoto(id: caption, image: image, caption: caption)}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
photos | [ScatteredPhotoBoardPhoto] | Required |
cardWidth | CGFloat | 150 |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct ScatteredPhotoBoardPhoto: Identifiable { let id: String let image: Image let caption: String}
enum ScatteredPhotoBoardLayout { /// A repeatable pseudo-random number from -0.5 to 0.5. private static func noise(_ index: Int, salt: Double) -> Double { let value = sin(Double(index % 10_000) * 12.9898 + salt) * 43_758.5453 return value - value.rounded(.down) - 0.5 }
static let maximumAngle = 9.0
static func angle(index: Int) -> Double { noise(index, salt: 7) * maximumAngle * 2 }
/// Keeps a card's center inside the board, or in its middle if the card does not fit. static func clamped(_ point: CGPoint, card: CGSize, board: CGSize) -> CGPoint { // A tilted card is wider and taller than it is straight. This is the box at the widest tilt. let tilt = maximumAngle * .pi / 180 let card = CGSize( width: card.width * cos(tilt) + card.height * sin(tilt), height: card.height * cos(tilt) + card.width * sin(tilt) ) func clamp(_ value: CGFloat, side: CGFloat, length: CGFloat) -> CGFloat { guard length > side else { return length / 2 } return min(max(value, side / 2), length - side / 2) } return CGPoint(x: clamp(point.x, side: card.width, length: board.width), y: clamp(point.y, side: card.height, length: board.height)) }
/// The offset from `home` to keep after a drag: where the card stopped, not how far the finger went. static func settledOffset(home: CGPoint, saved: CGSize, translation: CGSize, card: CGSize, board: CGSize) -> CGSize { let end = clamped( CGPoint(x: home.x + saved.width + translation.width, y: home.y + saved.height + translation.height), card: card, board: board ) return CGSize(width: end.x - home.x, height: end.y - home.y) }
/// A slightly jittered grid position, so the same photos always land in the same place. static func center(index: Int, count: Int, card: CGSize, board: CGSize) -> CGPoint { guard count > 0 else { return CGPoint(x: board.width / 2, y: board.height / 2) } let columns = max(Int(Double(min(count, 10_000)).squareRoot().rounded(.up)), 1) let rows = count / columns + (count.isMultiple(of: columns) ? 0 : 1) let cell = CGSize(width: board.width / CGFloat(columns), height: board.height / CGFloat(rows))
let point = CGPoint( x: (CGFloat(index % columns) + 0.5 + noise(index, salt: 1) * 0.35) * cell.width, y: (CGFloat(index / columns) + 0.5 + noise(index, salt: 3) * 0.35) * cell.height ) return clamped(point, card: card, board: board) }}
/// Photos scattered at slight angles on a board. Touching one brings it to the front, and it can/// be dragged anywhere on the board.////// VoiceOver reads each caption and offers an action to bring the photo to the front. With/// Reduce Motion on, a card does not lift while dragged.struct ScatteredPhotoBoard: View { private struct ActiveDrag { let id: String let translation: CGSize }
let photos: [ScatteredPhotoBoardPhoto] var cardWidth: CGFloat = 150
@State private var offsets: [String: CGSize] = [:] @State private var stacking: [String] = [] @GestureState private var drag: ActiveDrag? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection
private let space = "ScatteredPhotoBoard"
private var cardSize: CGSize { let width = max(cardWidth, 1) return CGSize(width: width, height: width * 1.2) }
var body: some View { GeometryReader { geometry in ZStack { ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in card(photo, index: index, board: geometry.size) .position(position(of: photo, index: index, board: geometry.size)) .zIndex(depth(of: photo, index: index)) } } .frame(width: geometry.size.width, height: geometry.size.height) .coordinateSpace(.named(space)) } }
private func card(_ photo: ScatteredPhotoBoardPhoto, index: Int, board: CGSize) -> some View { let isDragged = drag?.id == photo.id let lifted = isDragged && !reduceMotion
return VStack(spacing: 6) { Color.clear .aspectRatio(1, contentMode: .fit) .overlay { photo.image .resizable() .scaledToFill() } .clipped() Text(photo.caption) .font(.caption.weight(.medium)) .foregroundStyle(.black.opacity(0.75)) .lineLimit(1) .minimumScaleFactor(0.7) } .padding(8) .frame(width: cardSize.width) .background(.white, in: .rect(cornerRadius: 4)) .shadow(color: .black.opacity(lifted ? 0.4 : 0.25), radius: lifted ? 16 : 6, y: lifted ? 10 : 3) .contentShape(.rect) .gesture(dragGesture(for: photo, index: index, board: board)) .scaleEffect(lifted ? 1.06 : 1) .rotationEffect(.degrees(lifted ? 0 : ScatteredPhotoBoardLayout.angle(index: index))) .animation(reduceMotion ? nil : .snappy(duration: 0.25), value: isDragged) .accessibilityElement(children: .ignore) .accessibilityLabel(photo.caption) .accessibilityAddTraits(.isImage) .accessibilityAction(named: "Bring to front") { bringToFront(photo) } }
private func dragGesture(for photo: ScatteredPhotoBoardPhoto, index: Int, board: CGSize) -> some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .named(space)) .updating($drag) { value, state, _ in state = ActiveDrag(id: photo.id, translation: layoutTranslation(value.translation)) } .onChanged { _ in bringToFront(photo) } .onEnded { value in offsets[photo.id] = ScatteredPhotoBoardLayout.settledOffset( home: ScatteredPhotoBoardLayout.center(index: index, count: photos.count, card: cardSize, board: board), saved: offsets[photo.id] ?? .zero, translation: layoutTranslation(value.translation), card: cardSize, board: board ) } }
/// Gestures report screen points, but layout mirrors in right-to-left layouts. private func layoutTranslation(_ translation: CGSize) -> CGSize { layoutDirection == .rightToLeft ? CGSize(width: -translation.width, height: translation.height) : translation }
private func position(of photo: ScatteredPhotoBoardPhoto, index: Int, board: CGSize) -> CGPoint { let home = ScatteredPhotoBoardLayout.center(index: index, count: photos.count, card: cardSize, board: board) let saved = offsets[photo.id] ?? .zero let live = drag?.id == photo.id ? drag?.translation ?? .zero : .zero return ScatteredPhotoBoardLayout.clamped( CGPoint(x: home.x + saved.width + live.width, y: home.y + saved.height + live.height), card: cardSize, board: board ) }
private func depth(of photo: ScatteredPhotoBoardPhoto, index: Int) -> Double { guard let rank = stacking.firstIndex(of: photo.id) else { return Double(index) } return Double(photos.count + rank) }
private func bringToFront(_ photo: ScatteredPhotoBoardPhoto) { guard stacking.last != photo.id else { return } stacking.removeAll { $0 == photo.id } stacking.append(photo.id) }}
// MARK: - Demo
struct ScatteredPhotoBoardDemo: View { private let photos = [ demoPhoto("Lisbon", [.orange, .pink], "sun.max.fill"), demoPhoto("Oslo", [.cyan, .blue], "snowflake"), demoPhoto("Kyoto", [.pink, .purple], "leaf.fill"), demoPhoto("Quito", [.green, .teal], "mountain.2.fill"), demoPhoto("Cairo", [.yellow, .orange], "sparkles"), ]
var body: some View { ScatteredPhotoBoard(photos: photos) .background { LinearGradient( colors: [Color(red: 0.72, green: 0.55, blue: 0.38), Color(red: 0.55, green: 0.39, blue: 0.25)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() } }}
private func demoPhoto(_ caption: String, _ colors: [Color], _ symbol: String) -> ScatteredPhotoBoardPhoto { let size = CGSize(width: 400, height: 400) let image = Image(size: size, label: Text(caption)) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: 400, y: 400)) ) var icon = context.resolve(Image(systemName: symbol)) icon.shading = .color(.white.opacity(0.9)) context.draw(icon, in: CGRect(x: 120, y: 120, width: 160, height: 160)) } return ScatteredPhotoBoardPhoto(id: caption, image: image, caption: caption)}
#Preview { ScatteredPhotoBoardDemo()}