Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified Design/Showcase/snapshots/activityIndicator-brand-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Design/Showcase/snapshots/activityIndicator-brand-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Design/Showcase/snapshots/activityIndicator-system-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Design/Showcase/snapshots/activityIndicator-system-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion Requirements/Components/REQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Public `HIG*` SwiftUI components that implement Apple Human Interface Guidelines
- `HIGBadge`
- `HIGTag`
- `HIGProgressView`
- `HIGActivityIndicator`
- `HIGActivityIndicator` with styles: `system` (default), `orbital`, `pulsing`, `arcs`, `rotatingDots`, `flickeringDots`, `scalingDots`, `opacityDots`, `equalizer`, `growingCircle`, `gradient`
- `HIGAlert`
- `HIGToast`

Expand Down
1 change: 1 addition & 0 deletions Requirements/Modifiers/REQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Cross-cutting SwiftUI view modifiers that apply HIG tokens and accessibility beh
- Elevation: `higElevation`, `higShadow`
- Accessibility: `higAccessibilityLabel`, `higAccessibilityHint`, `higAccessibilityHidden`
- Motion: `higAnimation`, `higReduceMotionAware`
- Loading placeholders: `higShimmer` with mask, overlay, and background modes

### Behavior

Expand Down
4 changes: 4 additions & 0 deletions Scripts/verify_docc_coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ EXPECTED_SYMBOLS=(
HIGBadgeStyle
HIGActivityIndicator
HIGActivityIndicatorSize
HIGActivityIndicatorStyle
HIGList
HIGFormSection
HIGIcon
Expand All @@ -80,6 +81,9 @@ EXPECTED_SYMBOLS=(
HIGMenuButtonPresentation
HIGBrandTheme
HIGActivityIndicatorTokens
HIGShimmerMode
HIGShimmerTokens
higShimmer
HIGNavigationBarTextAction
HIGNavigationBarIconAction
HIGToolbarIconAction
Expand Down
70 changes: 66 additions & 4 deletions Showcase/Components/ShowcaseActivityIndicatorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,83 @@ import HIGDesign
import SwiftUI

struct ShowcaseActivityIndicatorView: View {
private let customStyles: [HIGActivityIndicatorStyle] = [
.orbital, .pulsing, .arcs, .rotatingDots, .flickeringDots,
.scalingDots, .opacityDots, .equalizer, .growingCircle, .gradient,
]

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
ShowcaseMetadataView(component: .activityIndicator)

VStack(alignment: .leading, spacing: 20) {
HIGActivityIndicator("Syncing library", size: .small)
HIGActivityIndicator("Syncing library", size: .medium)
HIGActivityIndicator("Syncing library", size: .large)
VStack(alignment: .leading, spacing: 12) {
Text("System")
.font(.caption)
.foregroundStyle(.secondary)

VStack(alignment: .leading, spacing: 20) {
HIGActivityIndicator("Syncing library", size: .small, style: .system)
HIGActivityIndicator("Syncing library", size: .medium, style: .system)
HIGActivityIndicator("Syncing library", size: .large, style: .system)
}
}

VStack(alignment: .leading, spacing: 12) {
Text("Custom styles")
.font(.caption)
.foregroundStyle(.secondary)

VStack(alignment: .leading, spacing: 20) {
ForEach(customStyles, id: \.self) { style in
HIGActivityIndicator(style.showcaseLabel, size: .medium, style: style)
}
}
}

VStack(alignment: .leading, spacing: 12) {
Text("Shimmer placeholder")
.font(.caption)
.foregroundStyle(.secondary)

shimmerPlaceholder
}
}
.higPadding(.screenEdge)
}
.navigationTitle("Activity Indicator")
}

private var shimmerPlaceholder: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Loading profile")
.font(.headline)
Text("Fetching account details and preferences.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.redacted(reason: .placeholder)
.higShimmer()
}
}

private extension HIGActivityIndicatorStyle {
var showcaseLabel: String {
switch self {
case .system: "System"
case .orbital: "Orbital"
case .pulsing: "Pulsing"
case .arcs: "Arcs"
case .rotatingDots: "Rotating dots"
case .flickeringDots: "Flickering dots"
case .scalingDots: "Scaling dots"
case .opacityDots: "Opacity dots"
case .equalizer: "Equalizer"
case .growingCircle: "Growing circle"
case .gradient: "Gradient"
}
}
}

#if DEBUG
Expand Down
153 changes: 144 additions & 9 deletions Sources/HIGComponents/Feedback/Activity/HIGActivityIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,26 @@ import SwiftUI
public struct HIGActivityIndicator: View {
private let label: String?
private let size: HIGActivityIndicatorSize
private let style: HIGActivityIndicatorStyle

@Environment(\.higTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion

public init(_ label: String? = nil, size: HIGActivityIndicatorSize = .medium) {
public init(
_ label: String? = nil,
size: HIGActivityIndicatorSize = .medium,
style: HIGActivityIndicatorStyle = .system
) {
self.label = label
self.size = size
self.style = style
}

public var body: some View {
let tokens = theme.activityIndicator
let capabilities = HIGPlatformCapabilities.current
let scale = size.scale(for: tokens)
let customDiameter = tokens.customDiameter * scale

VStack(alignment: .leading, spacing: theme.spacing.compactItem) {
if let label {
Expand All @@ -28,25 +36,152 @@ public struct HIGActivityIndicator: View {
.foregroundStyle(theme.colors.labelSecondary)
}

indicatorContent(
tokens: tokens,
capabilities: capabilities,
scale: scale,
customDiameter: customDiameter
)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(label ?? "Loading")
}

@ViewBuilder
private func indicatorContent(
tokens: any HIGActivityIndicatorTokens,
capabilities: HIGPlatformCapabilities,
scale: CGFloat,
customDiameter: CGFloat
) -> some View {
let accent = theme.colors.accent
let standardDuration = theme.motion.standard
let emphasizedDuration = theme.motion.emphasized

switch style {
case .system:
ProgressView()
.controlSize(size.controlSize(for: capabilities))
.scaleEffect(scale)
.tint(theme.colors.accent)
.tint(accent)
case .orbital:
HIGOrbitalActivityIndicator(
diameter: customDiameter,
lineWidth: tokens.orbitalLineWidth,
color: accent,
duration: emphasizedDuration,
reduceMotion: reduceMotion
)
case .pulsing:
HIGPulsingActivityIndicator(
diameter: customDiameter,
segmentCount: tokens.pulsingSegmentCount,
color: accent,
dimmedOpacity: tokens.pulsingDimmedOpacity,
duration: standardDuration,
reduceMotion: reduceMotion
)
case .arcs:
HIGArcsActivityIndicator(
diameter: customDiameter,
count: tokens.arcsCount,
lineWidth: tokens.orbitalLineWidth,
sweepDegrees: tokens.arcsSweepDegrees,
rotationSpeed: tokens.arcsRotationSpeed,
color: accent,
duration: emphasizedDuration,
reduceMotion: reduceMotion
)
case .rotatingDots:
HIGRotatingDotsActivityIndicator(
diameter: customDiameter,
count: tokens.rotatingDotsCount,
color: accent,
duration: emphasizedDuration,
reduceMotion: reduceMotion
)
case .flickeringDots:
HIGFlickeringDotsActivityIndicator(
diameter: customDiameter,
count: tokens.flickeringDotsCount,
color: accent,
minScale: tokens.scalingDotsMinScale,
minOpacity: tokens.pulsingDimmedOpacity,
duration: standardDuration,
reduceMotion: reduceMotion
)
case .scalingDots:
HIGScalingDotsActivityIndicator(
diameter: customDiameter,
count: tokens.scalingDotsCount,
inset: tokens.scalingDotsInset,
minScale: tokens.scalingDotsMinScale,
color: accent,
duration: standardDuration,
reduceMotion: reduceMotion
)
case .opacityDots:
HIGOpacityDotsActivityIndicator(
diameter: customDiameter,
count: tokens.opacityDotsCount,
inset: tokens.opacityDotsInset,
minScale: tokens.opacityDotsMinScale,
minOpacity: tokens.opacityDotsMinOpacity,
color: accent,
duration: standardDuration,
reduceMotion: reduceMotion
)
case .equalizer:
HIGEqualizerActivityIndicator(
diameter: customDiameter,
barCount: tokens.equalizerBarCount,
cornerRadius: tokens.equalizerBarCornerRadius,
minScale: tokens.equalizerMinScale,
color: accent,
duration: standardDuration,
reduceMotion: reduceMotion
)
case .growingCircle:
HIGGrowingCircleActivityIndicator(
diameter: customDiameter,
color: accent,
fadeOpacity: tokens.growingCircleFadeOpacity,
duration: emphasizedDuration,
reduceMotion: reduceMotion
)
case .gradient:
HIGGradientActivityIndicator(
diameter: customDiameter,
lineWidth: tokens.orbitalLineWidth,
trimLeading: tokens.gradientTrimLeading,
trimTrailing: tokens.gradientTrimTrailing,
colors: gradientColors(accent: accent),
duration: emphasizedDuration,
reduceMotion: reduceMotion
)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(label ?? "Loading")
}

private func gradientColors(accent: Color) -> [Color] {
[
accent.opacity(theme.opacity.subtleFill),
accent,
accent.opacity(theme.opacity.pressedSecondary),
]
}
}

#if DEBUG
#Preview("HIGActivityIndicator") {
HIGThemeableView(theme: HIGComponentPreviewTheme()) {
VStack(alignment: .leading, spacing: HIGSpacing.xl.rawValue) {
HIGActivityIndicator("Syncing", size: .small)
HIGActivityIndicator("Syncing", size: .medium)
HIGActivityIndicator(size: .large)
ScrollView {
VStack(alignment: .leading, spacing: HIGSpacing.xl.rawValue) {
ForEach(HIGActivityIndicatorStyle.allCases, id: \.self) { style in
HIGActivityIndicator(String(describing: style), size: .medium, style: style)
}
}
.padding()
}
.padding()
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// Visual style for ``HIGActivityIndicator``.
public enum HIGActivityIndicatorStyle: Sendable, CaseIterable {
/// Native `ProgressView` spinner (default, HIG-recommended).
case system
/// Token-backed growing arc (`growingArc`).
case orbital
/// Token-backed radial pulsing segments (`default`).
case pulsing
/// Concentric rotating arcs.
case arcs
/// Dots that orbit while scaling.
case rotatingDots
/// Radial dots that flicker in opacity and scale.
case flickeringDots
/// Horizontally aligned dots that scale in sequence.
case scalingDots
/// Horizontally aligned dots that pulse opacity.
case opacityDots
/// Vertical bars that scale like an equalizer.
case equalizer
/// Expanding circle that fades out.
case growingCircle
/// Rotating conic gradient ring.
case gradient
}
Loading