SwiftUI Attachment Tray Component

Files staged for sending, each with its own progress ring on the thumbnail rather than one shared bar — three uploads at three speeds cannot be described by a single percentage. Removal animates the remaining tiles closed so it is clear which one went.

PDF

Install the free SwiftUI attachment tray

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKAttachmentTray.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKAttachmentTray(attachments: files, onRemove: remove, onAdd: pick)

Full source · 237 lines · MIT

SKAttachmentTray.swift
//
//  SKAttachmentTray.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  Files staged for sending, with their uploads visible.
//
//  The detail that matters is the progress ring on the thumbnail itself rather
//  than a separate bar somewhere else. Three files uploading at different
//  speeds is the normal case, and one shared bar cannot describe it — you end
//  up with an aggregate percentage that is true of nothing the user can see.
//
//  Removal animates the remaining tiles closed. That sounds cosmetic and is
//  not: without it, tapping the X on the second of four attachments makes the
//  other three teleport, and the user cannot tell which one they actually
//  removed.
//
//  Usage:
//      SKAttachmentTray(attachments: $files, onRemove: { files.removeAll { $0.id == $1.id } })
//

import SwiftUI

public struct SKAttachment: Identifiable, Sendable {
    public enum Kind: Sendable {
        case image
        case file(ext: String)
        case audio
    }

    public let id: String
    public let name: String
    public let kind: Kind
    /// `nil` once the upload has finished. 0...1 while it is in flight.
    public let progress: Double?

    public init(
        id: String = UUID().uuidString,
        name: String,
        kind: Kind,
        progress: Double? = nil
    ) {
        self.id = id
        self.name = name
        self.kind = kind
        self.progress = progress
    }

    var symbol: String {
        switch kind {
        case .image: "photo"
        case .audio: "waveform"
        case .file: "doc.text"
        }
    }

    var badge: String? {
        if case let .file(ext) = kind { return ext.uppercased() }
        return nil
    }
}

public struct SKAttachmentTray: View {
    private let attachments: [SKAttachment]
    private let onRemove: ((SKAttachment) -> Void)?
    private let onAdd: (() -> Void)?

    private let tile: CGFloat = 62

    public init(
        attachments: [SKAttachment],
        onRemove: ((SKAttachment) -> Void)? = nil,
        onAdd: (() -> Void)? = nil
    ) {
        self.attachments = attachments
        self.onRemove = onRemove
        self.onAdd = onAdd
    }

    public var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: SK.Space.sm) {
                if let onAdd {
                    addTile(onAdd)
                }
                ForEach(attachments) { attachment in
                    tileView(attachment)
                        // Scale + opacity on both edges, so a tile leaves the
                        // way it arrived instead of being cut out.
                        .transition(.scale(scale: 0.6).combined(with: .opacity))
                }
            }
            .padding(.horizontal, SK.Space.lg)
            .padding(.vertical, SK.Space.sm)
        }
        .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
        .skAnimation(.settle, value: attachments.map(\.id))
    }

    private func addTile(_ action: @escaping () -> Void) -> some View {
        Button {
            action()
            SK.Haptic.play(.light)
        } label: {
            RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
                .strokeBorder(SK.Palette.separator, style: StrokeStyle(lineWidth: SK.Stroke.thin, dash: [4, 4]))
                .frame(width: tile, height: tile)
                .overlay {
                    Image(systemName: "plus")
                        .font(SK.Icon.symbol(SK.Icon.lg, weight: .medium))
                        .foregroundStyle(SK.Palette.labelTertiary)
                }
        }
        .buttonStyle(.skPress(scale: 0.94, haptic: nil))
        .accessibilityLabel("Add attachment")
    }

    private func tileView(_ attachment: SKAttachment) -> some View {
        ZStack(alignment: .topTrailing) {
            RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
                .fill(SK.Palette.fill)
                .frame(width: tile, height: tile)
                .overlay {
                    VStack(spacing: SK.Space.xs) {
                        Image(systemName: attachment.symbol)
                            .font(SK.Icon.symbol(SK.Icon.lg, weight: .regular))
                            .foregroundStyle(SK.Palette.labelSecondary)
                        if let badge = attachment.badge {
                            Text(badge)
                                .font(SK.Type_.monoCaption)
                                .foregroundStyle(SK.Palette.labelTertiary)
                        }
                    }
                }
                .overlay {
                    RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
                        .strokeBorder(SK.Palette.separator, lineWidth: SK.Stroke.thin)
                }
                .overlay {
                    if let progress = attachment.progress {
                        uploadOverlay(progress)
                    }
                }
                .clipShape(RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous))

            if onRemove != nil, attachment.progress == nil {
                removeButton(attachment)
            }
        }
        // Padding on the ZStack, not the tile, so the remove button can sit
        // proud of the corner without being clipped by the scroll view.
        .padding(.top, 6)
        .padding(.trailing, 6)
        .accessibilityElement(children: .combine)
        .accessibilityLabel(attachment.name)
        .accessibilityValue(attachment.progress.map { "Uploading, \(Int($0 * 100)) percent" } ?? "Ready")
    }

    private func uploadOverlay(_ progress: Double) -> some View {
        ZStack {
            // A scrim, because a ring drawn straight onto the thumbnail is
            // unreadable over a light image.
            Color.black.opacity(0.45)

            Circle()
                .stroke(Color.white.opacity(0.28), lineWidth: 3)
                .frame(width: 26, height: 26)

            Circle()
                .trim(from: 0, to: max(0.02, min(1, progress)))
                .stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
                .frame(width: 26, height: 26)
                .rotationEffect(.degrees(-90))
                .skAnimation(.glide, value: progress)
        }
    }

    private func removeButton(_ attachment: SKAttachment) -> some View {
        Button {
            onRemove?(attachment)
            SK.Haptic.play(.light)
        } label: {
            Image(systemName: "xmark")
                .font(SK.Icon.symbol(SK.Icon.xs, weight: .bold))
                .foregroundStyle(SK.Palette.surface)
                .frame(width: 19, height: 19)
                .background(SK.Palette.label, in: Circle())
                .overlay { Circle().strokeBorder(SK.Palette.surface, lineWidth: 1.5) }
        }
        .buttonStyle(.skPress(scale: 0.85, haptic: nil))
        .offset(x: 6, y: -6)
        .accessibilityLabel("Remove \(attachment.name)")
    }
}

#Preview("SKAttachmentTray") {
    @Previewable @State var files: [SKAttachment] = [
        SKAttachment(name: "q3-revenue.png", kind: .image),
        SKAttachment(name: "contract.pdf", kind: .file(ext: "pdf")),
        SKAttachment(name: "standup.m4a", kind: .audio, progress: 0.15),
        SKAttachment(name: "schema.json", kind: .file(ext: "json")),
    ]

    VStack(alignment: .leading, spacing: SK.Space.lg) {
        SKAttachmentTray(
            attachments: files,
            onRemove: { target in files.removeAll { $0.id == target.id } },
            onAdd: {
                files.append(SKAttachment(name: "new.png", kind: .image, progress: 0.05))
            }
        )

        Text("Tap × to remove. The upload ring fills on the third tile.")
            .font(SK.Type_.caption)
            .foregroundStyle(SK.Palette.labelTertiary)
            .padding(.horizontal, SK.Space.lg)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
    .padding(.top, SK.Space.xl)
    .background(SK.Palette.surface)
    .task {
        // Drive any in-flight uploads to completion so the ring is visibly live.
        while !Task.isCancelled {
            try? await Task.sleep(for: .milliseconds(260))
            files = files.map { file in
                guard let progress = file.progress else { return file }
                let next = progress + 0.12
                return SKAttachment(
                    id: file.id, name: file.name, kind: file.kind,
                    progress: next >= 1 ? nil : next
                )
            }
        }
    }
}

Questions

Is this SwiftUI attachment tray free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKAttachmentTray.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.