Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
iOS Human Interface Guidelines patterns for layout, typography, color, accessibility, and feedback in SwiftUI.
Omit spacing: on stacks to get SwiftUI's adaptive default. Only specify an explicit value when you need a deliberate departure from the default — and when you do, stick to the 4pt grid below.
This is a common design convention, not an Apple-prescribed system, but it keeps layouts visually coherent. Avoid inventing values between grid stops.
| Points | Token | Typical use |
|---|---|---|
| 4 | .xxSmall | Tight icon-to-label padding, inline badge offsets |
| 8 | .xSmall | Related elements within a group, compact stack gaps |
| 12 | .small | List row internal padding, label-to-secondary-text |
| 16 | .medium | Standard margin, default section gap |
| 20 | .mediumLarge | Comfortable breathing room between distinct controls |
| 24 | .large | Section separators, card internal padding |
| 32 | .xLarge | Major groupings, header-to-content gap |
| 40 | .xxLarge | Large section breaks |
| 48 | .xxxLarge | Hero/splash spacing, onboarding screens |
enum Spacing {
static let xxSmall: CGFloat = 4
static let xSmall: CGFloat = 8
static let small: CGFloat = 12
static let medium: CGFloat = 16
static let mediumLarge: CGFloat = 20
static let large: CGFloat = 24
static let xLarge: CGFloat = 32
static let xxLarge: CGFloat = 40
static let xxxLarge: CGFloat = 48
}private let standardMargin: CGFloat = 16
private let compactMargin: CGFloat = 8
private let largeMargin: CGFloat = 24
extension EdgeInsets {
static let standard = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
static let listRow = EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
}ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
.padding(.horizontal)
}
.safeAreaInset(edge: .bottom) {
HStack {
Button("Cancel") { }
.buttonStyle(.bordered)
Spacer()
Button("Confirm") { }
.buttonStyle(.borderedProminent)
}
.padding()
.background(.regularMaterial)
}Use horizontalSizeClass to adapt between compact and regular widths:
@Environment(\.horizontalSizeClass) private var sizeClass
private var columns: [GridItem] {
switch sizeClass {
case .compact:
[GridItem(.flexible())]
case .regular:
[GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
default:
[GridItem(.flexible())]
}
}Use system font styles for automatic Dynamic Type support:
| Style | Size | Weight | Usage |
|---|---|---|---|
.largeTitle | 34pt | Regular | Screen titles |
.title | 28pt | Regular | Section headers |
.title2 | 22pt | Regular | Sub-section headers |
.title3 | 20pt | Regular | Group headers |
.headline | 17pt | Semibold | Row titles |
.body | 17pt | Regular | Primary content |
.callout | 16pt | Regular | Secondary content |
.subheadline | 15pt | Regular | Supporting text |
.footnote | 13pt | Regular | Tertiary info |
.caption | 12pt | Regular | Labels |
.caption2 | 11pt | Regular | Small labels |
extension Font {
static func customBody(_ name: String) -> Font {
.custom(name, size: 17, relativeTo: .body)
}
}Use semantic colors for automatic light/dark mode support:
// Labels
Color.primary // Primary text
Color.secondary // Secondary text
Color(uiColor: .tertiaryLabel)
// Backgrounds
Color(uiColor: .systemBackground)
Color(uiColor: .secondarySystemBackground)
Color(uiColor: .systemGroupedBackground)
// Fills and Separators
Color(uiColor: .systemFill)
Color(uiColor: .separator)// Apply app-wide tint
ContentView()
.tint(.blue)Use .tint(...) or .foregroundStyle(.tint) for interactive elements and Color.red for destructive actions.
Use for iPad/macOS multi-column layouts:
NavigationSplitView {
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) { ItemRow(item: item) }
}
.navigationTitle("Items")
} detail: {
if let item = selectedItem {
ItemDetailView(item: item)
} else {
ContentUnavailableView("Select an Item", systemImage: "sidebar.leading")
}
}Use TabView with a NavigationStack per tab. See the swiftui-navigation skill for full tab patterns.
.toolbar {
ToolbarItem(placement: .topBarLeading) { EditButton() }
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
Button("Add", systemImage: "plus") { }
}
ToolbarItemGroup(placement: .bottomBar) {
Button("Archive", systemImage: "archivebox") { }
Spacer()
Text("\(itemCount) items").font(.footnote).foregroundStyle(.secondary)
Spacer()
Button("Share", systemImage: "square.and.arrow.up") { }
}
}.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.rawValue.capitalized).tag(scope)
}
}Prefer SwiftUI's sensoryFeedback(_:trigger:) for state-driven feedback in SwiftUI views.
Button("Save") {
didSave.toggle()
}
.sensoryFeedback(.success, trigger: didSave)
Picker("Sort", selection: $sortOrder) {
Text("Recent").tag(SortOrder.recent)
Text("Popular").tag(SortOrder.popular)
}
.sensoryFeedback(.selection, trigger: sortOrder)Use the UIKit generators only when you need imperative feedback from UIKit or non-SwiftUI integration points.
See the Haptics section below for structured patterns.
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline).foregroundStyle(.secondary)
HStack {
Image(systemName: "star.fill")
Text("\(item.rating, specifier: "%.1f")")
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.title), \(item.subtitle)")
.accessibilityValue("Rating: \(item.rating) stars")
.accessibilityHint("Double tap to view details")
.accessibilityAddTraits(.isButton)Adapt layout for accessibility sizes:
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack(alignment: .leading) {
leadingContent
trailingContent
}
} else {
HStack {
leadingContent
Spacer()
trailingContent
}
}
}Use ContentUnavailableView for both:
// Error state
ContentUnavailableView {
Label("Unable to Load", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Try Again") { Task { await retry() } }
.buttonStyle(.borderedProminent)
}
// Empty state
ContentUnavailableView {
Label("No Photos", systemImage: "camera")
} description: {
Text("Take your first photo to get started.")
} actions: {
Button("Take Photo") { showCamera = true }
.buttonStyle(.borderedProminent)
}Provide a clean, scalable theming approach that keeps view code semantic and consistent.
Theme object as the source of truth (colors, fonts, spacing).@Environment(Theme.self) in views.primaryBackground, secondaryBackground, label, tint) instead of raw colors.Font.custom(_:size:relativeTo:), or @ScaledMetric for numeric layout values.@MainActor
@Observable
final class Theme {
var tintColor: Color = .blue
var primaryBackground: Color = .white
var secondaryBackground: Color = .gray.opacity(0.1)
var labelColor: Color = .primary
var fontSizeScale: Double = 1.0
}@main
struct MyApp: App {
@State private var theme = Theme()
var body: some Scene {
WindowGroup {
AppView()
.environment(theme)
}
}
}struct ProfileView: View {
@Environment(Theme.self) private var theme
var body: some View {
VStack {
Text("Profile")
.foregroundStyle(theme.labelColor)
}
.background(theme.primaryBackground)
}
}Color values in views; it breaks consistency.@Environment(\.colorScheme) as the only theme control; it should complement your theme.Use haptics sparingly to reinforce user actions (tab selection, refresh, success/error) and respect user preferences.
sensoryFeedback(_:trigger:) in SwiftUI views for state-driven feedback.HapticManager only when UIKit interop or non-view code requires it.struct SaveButton: View {
@State private var saveToken = 0
var body: some View {
Button("Save") {
persistChanges()
saveToken += 1
}
.sensoryFeedback(.success, trigger: saveToken)
}
}@MainActor
final class HapticManager {
static let shared = HapticManager()
enum HapticType {
case buttonPress
case tabSelection
case dataRefresh(intensity: CGFloat)
case notification(UINotificationFeedbackGenerator.FeedbackType)
}
private let selectionGenerator = UISelectionFeedbackGenerator()
private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let notificationGenerator = UINotificationFeedbackGenerator()
private init() { selectionGenerator.prepare() }
func fire(_ type: HapticType, isEnabled: Bool) {
guard isEnabled else { return }
switch type {
case .buttonPress:
impactGenerator.impactOccurred()
case .tabSelection:
selectionGenerator.selectionChanged()
case let .dataRefresh(intensity):
impactGenerator.impactOccurred(intensity: intensity)
case let .notification(style):
notificationGenerator.notificationOccurred(style)
}
}
}Button("Save") {
HapticManager.shared.fire(.notification(.success), isEnabled: preferences.hapticsEnabled)
}
TabView(selection: $selectedTab) { /* tabs */ }
.onChange(of: selectedTab) { _, _ in
HapticManager.shared.fire(.tabSelection, isEnabled: preferences.hapticTabSelectionEnabled)
}For advanced haptic patterns beyond the simple feedback generators, use Core Haptics. It provides precise control over haptic intensity, sharpness, and timing with support for audio-haptic synchronization.
Always verify hardware support before creating an engine:
import CoreHaptics
let supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics
let supportsAudio = CHHapticEngine.capabilitiesForHardware().supportsAudio@MainActor
final class CoreHapticManager {
private var engine: CHHapticEngine?
func prepareEngine() throws {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
engine = try CHHapticEngine()
// Called when the engine stops due to external cause (audio session interruption, app backgrounding)
engine?.stoppedHandler = { reason in
print("Haptic engine stopped: \(reason)")
}
// Called after the engine is reset (e.g., after audio session interruption ends)
engine?.resetHandler = { [weak self] in
do {
try self?.engine?.start()
} catch {
print("Failed to restart engine: \(error)")
}
}
try engine?.start()
}
func stopEngine() {
engine?.stop()
}
}Key lifecycle rules:
engine.start() before playing any patterns.stoppedHandler — the system can stop the engine when your app moves to the background or during audio interruptions.resetHandler — restart the engine when the system resets it.engine.stop() when haptics are no longer needed to save battery.Build patterns from individual haptic and audio events:
func playTransientTap() throws {
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8)
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0)
// Transient: short, single-tap feel
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [intensity, sharpness],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
}Event types:
| Type | Description |
|---|---|
.hapticTransient | Brief, tap-like impulse |
.hapticContinuous | Sustained vibration over a duration |
.audioContinuous | Sustained audio tone |
.audioCustom | Play a custom audio resource |
Common parameters: .hapticIntensity (0–1), .hapticSharpness (0–1), .attackTime, .decayTime, .releaseTime.
func playContinuousBuzz() throws {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6)
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [intensity, sharpness],
relativeTime: 0,
duration: 0.5
)
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
}For looping, seeking, and pausing, use CHHapticAdvancedPatternPlayer via engine.makeAdvancedPlayer(with:).
Smoothly vary parameters over time within a pattern:
func playRampingPattern() throws {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.2),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.1)
],
relativeTime: 0,
duration: 1.0
)
// Ramp intensity from 0.2 → 1.0 over 1 second
let curve = CHHapticParameterCurve(
parameterID: .hapticIntensityControl,
controlPoints: [
.init(relativeTime: 0, value: 0.2),
.init(relativeTime: 0.5, value: 0.7),
.init(relativeTime: 1.0, value: 1.0)
],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [event], parameterCurves: [curve])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
}AHAP (Apple Haptic and Audio Pattern) files define haptic patterns in JSON for easy authoring and design iteration. Load them directly:
func playAHAPFile() throws {
guard let url = Bundle.main.url(forResource: "success", withExtension: "ahap") else { return }
try engine?.playPattern(from: url)
}AHAP files support the same events, parameters, and parameter curves as the programmatic API. Use the Core Haptics design tools in Xcode to preview patterns.
Use matched transitions to create smooth continuity between a source view (thumbnail, avatar) and a destination view (sheet, detail, viewer).
Namespace and a stable ID for the source.matchedTransitionSource + navigationTransition(.zoom(...)) on iOS 26+.matchedGeometryEffect for in-place transitions within a view hierarchy.struct MediaPreview: View {
@Namespace private var namespace
@State private var selected: MediaAttachment?
var body: some View {
ThumbnailView()
.matchedTransitionSource(id: selected?.id ?? "", in: namespace)
.sheet(item: $selected) { item in
MediaViewer(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
}
}
}struct ToggleBadge: View {
@Namespace private var space
@State private var isOn = false
var body: some View {
Button {
withAnimation(.spring) { isOn.toggle() }
} label: {
Image(systemName: isOn ? "eye" : "eye.slash")
.matchedGeometryEffect(id: "icon", in: space)
}
}
}matchedTransitionSource for cross-screen transitions.withAnimation for state-driven transitions.Use this when a view needs a consistent loading state (skeletons, redaction, empty state) without blocking interaction.
.redacted(reason: .placeholder).ContentUnavailableView when load finishes but data is empty.VStack {
if isLoading {
ForEach(0..<3, id: \.self) { _ in
RowView(model: .placeholder())
}
.redacted(reason: .placeholder)
} else if items.isEmpty {
ContentUnavailableView("No items", systemImage: "tray")
} else {
ForEach(items) { item in RowView(model: item) }
}
}This file covers basic form-focus patterns only. For directional focus, focus sections, scene-focused values, and UIFocusGuide, see the focus-engine skill.
Use @FocusState to control keyboard focus, chain fields, and coordinate focus across complex forms.
onAppear..onSubmit to move focus to the next field..option(Int)).struct AddServerView: View {
@State private var server = ""
@FocusState private var isServerFieldFocused: Bool
var body: some View {
Form {
TextField("Server", text: $server)
.focused($isServerFieldFocused)
}
.onAppear { isServerFieldFocused = true }
}
}struct EditTagView: View {
enum FocusField { case title, symbol, newTag }
@FocusState private var focusedField: FocusField?
var body: some View {
Form {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
.onSubmit { focusedField = .symbol }
TextField("Symbol", text: $symbol)
.focused($focusedField, equals: .symbol)
.onSubmit { focusedField = .newTag }
}
.onAppear { focusedField = .title }
}
}struct PollView: View {
enum FocusField: Hashable { case option(Int) }
@FocusState private var focused: FocusField?
@State private var options: [String] = ["", ""]
@State private var currentIndex = 0
var body: some View {
ForEach(options.indices, id: \.self) { index in
TextField("Option \(index + 1)", text: $options[index])
.focused($focused, equals: .option(index))
.onSubmit { addOption(at: index) }
}
.onAppear { focused = .option(0) }
}
private func addOption(at index: Int) {
options.append("")
currentIndex = index + 1
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(10))
focused = .option(currentIndex)
}
}
}.scrollDismissesKeyboard(...) when using ScrollView/Form.skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references