diff --git a/Design/Showcase/snapshots/activityIndicator-brand-dark.png b/Design/Showcase/snapshots/activityIndicator-brand-dark.png index 55e7c0a..759692e 100644 Binary files a/Design/Showcase/snapshots/activityIndicator-brand-dark.png and b/Design/Showcase/snapshots/activityIndicator-brand-dark.png differ diff --git a/Design/Showcase/snapshots/activityIndicator-brand-light.png b/Design/Showcase/snapshots/activityIndicator-brand-light.png index a0478df..41a4897 100644 Binary files a/Design/Showcase/snapshots/activityIndicator-brand-light.png and b/Design/Showcase/snapshots/activityIndicator-brand-light.png differ diff --git a/Design/Showcase/snapshots/activityIndicator-highContrast-dark.png b/Design/Showcase/snapshots/activityIndicator-highContrast-dark.png index 55e7c0a..86f2dd2 100644 Binary files a/Design/Showcase/snapshots/activityIndicator-highContrast-dark.png and b/Design/Showcase/snapshots/activityIndicator-highContrast-dark.png differ diff --git a/Design/Showcase/snapshots/activityIndicator-highContrast-light.png b/Design/Showcase/snapshots/activityIndicator-highContrast-light.png index a0478df..364c413 100644 Binary files a/Design/Showcase/snapshots/activityIndicator-highContrast-light.png and b/Design/Showcase/snapshots/activityIndicator-highContrast-light.png differ diff --git a/Design/Showcase/snapshots/activityIndicator-system-dark.png b/Design/Showcase/snapshots/activityIndicator-system-dark.png index 55e7c0a..815624c 100644 Binary files a/Design/Showcase/snapshots/activityIndicator-system-dark.png and b/Design/Showcase/snapshots/activityIndicator-system-dark.png differ diff --git a/Design/Showcase/snapshots/activityIndicator-system-light.png b/Design/Showcase/snapshots/activityIndicator-system-light.png index a0478df..1e28bf5 100644 Binary files a/Design/Showcase/snapshots/activityIndicator-system-light.png and b/Design/Showcase/snapshots/activityIndicator-system-light.png differ diff --git a/Design/Showcase/snapshots/platforms/ios/activityIndicator-system-light.png b/Design/Showcase/snapshots/platforms/ios/activityIndicator-system-light.png index 297c1c7..23211b5 100644 Binary files a/Design/Showcase/snapshots/platforms/ios/activityIndicator-system-light.png and b/Design/Showcase/snapshots/platforms/ios/activityIndicator-system-light.png differ diff --git a/Design/Showcase/snapshots/platforms/ipados/activityIndicator-system-light.png b/Design/Showcase/snapshots/platforms/ipados/activityIndicator-system-light.png index bb4f040..b92d600 100644 Binary files a/Design/Showcase/snapshots/platforms/ipados/activityIndicator-system-light.png and b/Design/Showcase/snapshots/platforms/ipados/activityIndicator-system-light.png differ diff --git a/Design/Showcase/snapshots/platforms/macos/activityIndicator-system-light.png b/Design/Showcase/snapshots/platforms/macos/activityIndicator-system-light.png index b5d1643..15bfadc 100644 Binary files a/Design/Showcase/snapshots/platforms/macos/activityIndicator-system-light.png and b/Design/Showcase/snapshots/platforms/macos/activityIndicator-system-light.png differ diff --git a/Design/Showcase/snapshots/platforms/tvos/activityIndicator-system-light.png b/Design/Showcase/snapshots/platforms/tvos/activityIndicator-system-light.png index 68411e3..31ca705 100644 Binary files a/Design/Showcase/snapshots/platforms/tvos/activityIndicator-system-light.png and b/Design/Showcase/snapshots/platforms/tvos/activityIndicator-system-light.png differ diff --git a/Design/Showcase/snapshots/platforms/visionos/activityIndicator-system-light.png b/Design/Showcase/snapshots/platforms/visionos/activityIndicator-system-light.png index b5d1643..7ba3b38 100644 Binary files a/Design/Showcase/snapshots/platforms/visionos/activityIndicator-system-light.png and b/Design/Showcase/snapshots/platforms/visionos/activityIndicator-system-light.png differ diff --git a/Design/Showcase/snapshots/platforms/watchos/activityIndicator-system-light.png b/Design/Showcase/snapshots/platforms/watchos/activityIndicator-system-light.png index 364282e..3f3f460 100644 Binary files a/Design/Showcase/snapshots/platforms/watchos/activityIndicator-system-light.png and b/Design/Showcase/snapshots/platforms/watchos/activityIndicator-system-light.png differ diff --git a/Requirements/Components/REQ.md b/Requirements/Components/REQ.md index fe37a63..f692c0f 100644 --- a/Requirements/Components/REQ.md +++ b/Requirements/Components/REQ.md @@ -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` diff --git a/Requirements/Modifiers/REQ.md b/Requirements/Modifiers/REQ.md index d27d701..ed2b055 100644 --- a/Requirements/Modifiers/REQ.md +++ b/Requirements/Modifiers/REQ.md @@ -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 diff --git a/Scripts/verify_docc_coverage.sh b/Scripts/verify_docc_coverage.sh index a2f5e92..3113676 100755 --- a/Scripts/verify_docc_coverage.sh +++ b/Scripts/verify_docc_coverage.sh @@ -63,6 +63,7 @@ EXPECTED_SYMBOLS=( HIGBadgeStyle HIGActivityIndicator HIGActivityIndicatorSize + HIGActivityIndicatorStyle HIGList HIGFormSection HIGIcon @@ -80,6 +81,9 @@ EXPECTED_SYMBOLS=( HIGMenuButtonPresentation HIGBrandTheme HIGActivityIndicatorTokens + HIGShimmerMode + HIGShimmerTokens + higShimmer HIGNavigationBarTextAction HIGNavigationBarIconAction HIGToolbarIconAction diff --git a/Showcase/Components/ShowcaseActivityIndicatorView.swift b/Showcase/Components/ShowcaseActivityIndicatorView.swift index 7835739..1dedcf9 100644 --- a/Showcase/Components/ShowcaseActivityIndicatorView.swift +++ b/Showcase/Components/ShowcaseActivityIndicatorView.swift @@ -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 diff --git a/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicator.swift b/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicator.swift index b6b0061..be9bc8a 100644 --- a/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicator.swift +++ b/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicator.swift @@ -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 { @@ -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 \ No newline at end of file diff --git a/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicatorStyle.swift b/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicatorStyle.swift new file mode 100644 index 0000000..668ceeb --- /dev/null +++ b/Sources/HIGComponents/Feedback/Activity/HIGActivityIndicatorStyle.swift @@ -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 +} \ No newline at end of file diff --git a/Sources/HIGComponents/Feedback/Activity/HIGArcsActivityIndicator.swift b/Sources/HIGComponents/Feedback/Activity/HIGArcsActivityIndicator.swift new file mode 100644 index 0000000..ce27f49 --- /dev/null +++ b/Sources/HIGComponents/Feedback/Activity/HIGArcsActivityIndicator.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct HIGArcsActivityIndicator: View { + let diameter: CGFloat + let count: Int + let lineWidth: CGFloat + let sweepDegrees: CGFloat + let rotationSpeed: Double + let color: Color + let duration: TimeInterval + let reduceMotion: Bool + + var body: some View { + GeometryReader { geometry in + let size = geometry.size + ForEach(0.. Path { + var path = Path() + path.addArc( + center: CGPoint(x: rect.midX, y: rect.midY), + radius: radius, + startAngle: .degrees(0), + endAngle: .degrees(Double(sweepDegrees)), + clockwise: true + ) + return path + } +} \ No newline at end of file diff --git a/Sources/HIGComponents/Feedback/Activity/HIGEqualizerActivityIndicator.swift b/Sources/HIGComponents/Feedback/Activity/HIGEqualizerActivityIndicator.swift new file mode 100644 index 0000000..f512db8 --- /dev/null +++ b/Sources/HIGComponents/Feedback/Activity/HIGEqualizerActivityIndicator.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct HIGEqualizerActivityIndicator: View { + let diameter: CGFloat + let barCount: Int + let cornerRadius: CGFloat + let minScale: CGFloat + let color: Color + let duration: TimeInterval + let reduceMotion: Bool + + var body: some View { + GeometryReader { geometry in + let size = geometry.size + ForEach(0.. Path { + let maxLength = 2 * Double.pi - 0.7 + let lag = 0.35 + let normalized = progress * 2 + var length = normalized * maxLength + + if normalized > 1, normalized < lag + 1 { + length = maxLength + } else if normalized > lag + 1 { + let coefficient = 1 / (1 - lag) + let remainder = normalized - 1 - lag + length = (1 - remainder * coefficient) * maxLength + } + + let leadingAngle = Double.pi / 2 + let trailingSweep = 4 * Double.pi - leadingAngle + var endAngle = normalized * leadingAngle + if normalized > 1 { + endAngle = leadingAngle + (normalized - 1) * trailingSweep + } + + let startAngle = endAngle + length + let radius = min(rect.width, rect.height) / 2 + let center = CGPoint(x: rect.midX, y: rect.midY) + + var path = Path() + path.addArc( + center: center, + radius: radius, + startAngle: Angle(radians: startAngle), + endAngle: Angle(radians: endAngle), + clockwise: true + ) + return path + } +} \ No newline at end of file diff --git a/Sources/HIGComponents/Feedback/Activity/HIGPulsingActivityIndicator.swift b/Sources/HIGComponents/Feedback/Activity/HIGPulsingActivityIndicator.swift new file mode 100644 index 0000000..dfde19d --- /dev/null +++ b/Sources/HIGComponents/Feedback/Activity/HIGPulsingActivityIndicator.swift @@ -0,0 +1,76 @@ +import HIGThemesContract +import HIGTokensComponent +import SwiftUI + +struct HIGPulsingActivityIndicator: View { + let diameter: CGFloat + let segmentCount: Int + let color: Color + let dimmedOpacity: CGFloat + let duration: TimeInterval + let reduceMotion: Bool + + var body: some View { + GeometryReader { geometry in + let size = geometry.size + ForEach(0.. PHImageRequestOptions { - let options = PHImageRequestOptions() - options.deliveryMode = .fastFormat - options.resizeMode = .fast - options.version = .current - options.isNetworkAccessAllowed = allowsNetwork - return options - } - #if DEBUG private func previewThumbnail(for asset: HIGPhotoAsset) throws -> ImageLoadResult { let seed = PreviewColorSeed.value(for: asset.id) diff --git a/Sources/HIGComponents/Inputs/PhotoPicker/PhotoKitCoordinator.swift b/Sources/HIGComponents/Inputs/PhotoPicker/PhotoKitCoordinator.swift index de826c0..c6b5cf8 100644 --- a/Sources/HIGComponents/Inputs/PhotoPicker/PhotoKitCoordinator.swift +++ b/Sources/HIGComponents/Inputs/PhotoPicker/PhotoKitCoordinator.swift @@ -37,10 +37,11 @@ actor PhotoKitCoordinator { } } - func startCaching(assetIDs: [String], targetSize: CGSize, options: PHImageRequestOptions) { + func startCaching(assetIDs: [String], targetSize: CGSize, allowsNetwork: Bool) { let phAssets = assetIDs.compactMap { cachedAsset(for: $0) } guard !phAssets.isEmpty else { return } + let options = thumbnailRequestOptions(allowsNetwork: allowsNetwork) let pixelSize = PhotoKitImageSizing.pixelSize(for: targetSize, scale: displayScale) imageManager.startCachingImages( for: phAssets, @@ -50,10 +51,11 @@ actor PhotoKitCoordinator { ) } - func stopCaching(assetIDs: [String], targetSize: CGSize, options: PHImageRequestOptions) { + func stopCaching(assetIDs: [String], targetSize: CGSize, allowsNetwork: Bool) { let phAssets = assetIDs.compactMap { cachedAsset(for: $0) } guard !phAssets.isEmpty else { return } + let options = thumbnailRequestOptions(allowsNetwork: allowsNetwork) let pixelSize = PhotoKitImageSizing.pixelSize(for: targetSize, scale: displayScale) imageManager.stopCachingImages( for: phAssets, @@ -194,6 +196,15 @@ actor PhotoKitCoordinator { ) } + private func thumbnailRequestOptions(allowsNetwork: Bool) -> PHImageRequestOptions { + let options = PHImageRequestOptions() + options.deliveryMode = .fastFormat + options.resizeMode = .fast + options.version = .current + options.isNetworkAccessAllowed = allowsNetwork + return options + } + private func cancelRequest(for key: String) { guard let requestID = activeRequests.removeValue(forKey: key) else { return } imageManager.cancelImageRequest(requestID) diff --git a/Sources/HIGDesign/HIGDesign.docc/Components.md b/Sources/HIGDesign/HIGDesign.docc/Components.md index 4d24978..5f0b181 100644 --- a/Sources/HIGDesign/HIGDesign.docc/Components.md +++ b/Sources/HIGDesign/HIGDesign.docc/Components.md @@ -53,6 +53,7 @@ HIGDesign ships SwiftUI-native components and modifiers that read from ``HIGThem - ``HIGProgressView`` - ``HIGActivityIndicator`` - ``HIGActivityIndicatorSize`` +- ``HIGActivityIndicatorStyle`` - ``HIGAlert`` - ``HIGAlertButtonRole`` - ``HIGAlertBanner`` @@ -90,4 +91,7 @@ HIGDesign ships SwiftUI-native components and modifiers that read from ``HIGThem ## Modifiers -- ``higPadding(_:)`` \ No newline at end of file +- ``higPadding(_:)`` +- ``higShimmer(isActive:mode:)`` +- ``HIGShimmerMode`` +- ``HIGShimmerTokens`` \ No newline at end of file diff --git a/Sources/HIGDesign/HIGDesign.docc/HIGDesign.md b/Sources/HIGDesign/HIGDesign.docc/HIGDesign.md index 81a7307..bea3313 100644 --- a/Sources/HIGDesign/HIGDesign.docc/HIGDesign.md +++ b/Sources/HIGDesign/HIGDesign.docc/HIGDesign.md @@ -111,6 +111,7 @@ HIGThemeableView(theme: HIGSystemTheme()) { - ``HIGProgressView`` - ``HIGActivityIndicator`` - ``HIGActivityIndicatorSize`` +- ``HIGActivityIndicatorStyle`` - ``HIGAlert`` - ``HIGAlertButtonRole`` - ``HIGAlertBanner`` @@ -132,4 +133,7 @@ HIGThemeableView(theme: HIGSystemTheme()) { - ``higToastQueue(_:)`` - ``higNavigationBarTitle(_:displayMode:)`` - ``higNavigationBar(_:displayMode:leading:trailing:)`` -- ``HIGNavigationBarDisplayMode`` \ No newline at end of file +- ``HIGNavigationBarDisplayMode`` +- ``higShimmer(isActive:mode:)`` +- ``HIGShimmerMode`` +- ``HIGShimmerTokens`` \ No newline at end of file diff --git a/Sources/HIGModifiers/Feedback/HIGShimmerMode.swift b/Sources/HIGModifiers/Feedback/HIGShimmerMode.swift new file mode 100644 index 0000000..50e28cb --- /dev/null +++ b/Sources/HIGModifiers/Feedback/HIGShimmerMode.swift @@ -0,0 +1,8 @@ +import SwiftUI + +/// Placement mode for ``higShimmer(isActive:mode:)``. +public enum HIGShimmerMode: Sendable { + case mask + case overlay(blendMode: BlendMode = .sourceAtop) + case background +} \ No newline at end of file diff --git a/Sources/HIGModifiers/Feedback/HIGShimmerModifier.swift b/Sources/HIGModifiers/Feedback/HIGShimmerModifier.swift new file mode 100644 index 0000000..3f4819f --- /dev/null +++ b/Sources/HIGModifiers/Feedback/HIGShimmerModifier.swift @@ -0,0 +1,88 @@ +import HIGThemesContract +import SwiftUI + +extension View { + /// Adds a token-backed shimmering effect, typically over ``redacted(reason: .placeholder)`` content. + public func higShimmer(isActive: Bool = true, mode: HIGShimmerMode = .mask) -> some View { + modifier(HIGShimmerModifier(isActive: isActive, mode: mode)) + } +} + +private struct HIGShimmerModifier: ViewModifier { + let isActive: Bool + let mode: HIGShimmerMode + + @Environment(\.higTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.layoutDirection) private var layoutDirection + @State private var isInitialState = true + + func body(content: Content) -> some View { + if isActive, !reduceMotion { + applyingShimmer(to: content) + .animation(shimmerAnimation, value: isInitialState) + .onAppear { + Task { @MainActor in + isInitialState = false + } + } + } else { + content + } + } + + private var shimmerAnimation: Animation { + let tokens = theme.shimmer + return .linear(duration: tokens.animationDuration) + .delay(tokens.animationDelay) + .repeatForever(autoreverses: false) + } + + @ViewBuilder + private func applyingShimmer(to content: Content) -> some View { + let gradient = LinearGradient( + gradient: Gradient(colors: shimmerColors), + startPoint: startPoint, + endPoint: endPoint + ) + + switch mode { + case .mask: + content.mask(gradient) + case let .overlay(blendMode): + content.overlay(gradient.blendMode(blendMode)) + case .background: + content.background(gradient) + } + } + + private var shimmerColors: [Color] { + let tokens = theme.shimmer + let base = theme.colors.fillPrimary + return [ + base.opacity(tokens.baseOpacity), + base.opacity(tokens.highlightOpacity), + base.opacity(tokens.baseOpacity), + ] + } + + private var startPoint: UnitPoint { + let band = theme.shimmer.bandSize + let min = 0 - band + let max = 1 + band + if layoutDirection == .rightToLeft { + return isInitialState ? UnitPoint(x: max, y: min) : UnitPoint(x: 0, y: 1) + } + return isInitialState ? UnitPoint(x: min, y: min) : UnitPoint(x: 1, y: 1) + } + + private var endPoint: UnitPoint { + let band = theme.shimmer.bandSize + let min = 0 - band + let max = 1 + band + if layoutDirection == .rightToLeft { + return isInitialState ? UnitPoint(x: 1, y: 0) : UnitPoint(x: min, y: max) + } + return isInitialState ? UnitPoint(x: 0, y: 0) : UnitPoint(x: max, y: max) + } +} \ No newline at end of file diff --git a/Sources/HIGThemesContract/HIGTheme.swift b/Sources/HIGThemesContract/HIGTheme.swift index cba6d29..5f10033 100644 --- a/Sources/HIGThemesContract/HIGTheme.swift +++ b/Sources/HIGThemesContract/HIGTheme.swift @@ -34,6 +34,7 @@ public protocol HIGTheme: Sendable { var navigationBar: any HIGNavigationBarTokens { get } var toolbar: any HIGToolbarTokens { get } var activityIndicator: any HIGActivityIndicatorTokens { get } + var shimmer: any HIGShimmerTokens { get } var list: any HIGListTokens { get } var sidebar: any HIGSidebarTokens { get } var photoPicker: any HIGPhotoPickerTokens { get } diff --git a/Sources/HIGThemesContract/Preview/HIGComponentPreviewTheme.swift b/Sources/HIGThemesContract/Preview/HIGComponentPreviewTheme.swift index 0e0d862..04fcf07 100644 --- a/Sources/HIGThemesContract/Preview/HIGComponentPreviewTheme.swift +++ b/Sources/HIGThemesContract/Preview/HIGComponentPreviewTheme.swift @@ -35,6 +35,7 @@ public struct HIGComponentPreviewTheme: HIGTheme { public let navigationBar: any HIGNavigationBarTokens = HIGSystemNavigationBarTokens() public let toolbar: any HIGToolbarTokens = HIGSystemToolbarTokens() public let activityIndicator: any HIGActivityIndicatorTokens = HIGSystemActivityIndicatorTokens() + public let shimmer: any HIGShimmerTokens = HIGSystemShimmerTokens() public let list: any HIGListTokens = HIGSystemListTokens() public let sidebar: any HIGSidebarTokens = HIGSystemSidebarTokens() public let photoPicker: any HIGPhotoPickerTokens = HIGSystemPhotoPickerTokens() diff --git a/Sources/HIGThemesSystem/HIGBrandTheme.swift b/Sources/HIGThemesSystem/HIGBrandTheme.swift index b00f59a..9f1486f 100644 --- a/Sources/HIGThemesSystem/HIGBrandTheme.swift +++ b/Sources/HIGThemesSystem/HIGBrandTheme.swift @@ -37,6 +37,7 @@ public struct HIGBrandTheme: HIGTheme, Sendable { public let navigationBar: any HIGNavigationBarTokens public let toolbar: any HIGToolbarTokens public let activityIndicator: any HIGActivityIndicatorTokens + public let shimmer: any HIGShimmerTokens public let list: any HIGListTokens public let sidebar: any HIGSidebarTokens public let photoPicker: any HIGPhotoPickerTokens @@ -90,6 +91,7 @@ public struct HIGBrandTheme: HIGTheme, Sendable { self.navigationBar = base.navigationBar self.toolbar = base.toolbar self.activityIndicator = base.activityIndicator + self.shimmer = base.shimmer self.list = base.list self.sidebar = base.sidebar self.photoPicker = base.photoPicker diff --git a/Sources/HIGThemesSystem/HIGHighContrastTheme.swift b/Sources/HIGThemesSystem/HIGHighContrastTheme.swift index c2299e4..9f96515 100644 --- a/Sources/HIGThemesSystem/HIGHighContrastTheme.swift +++ b/Sources/HIGThemesSystem/HIGHighContrastTheme.swift @@ -36,6 +36,7 @@ public struct HIGHighContrastTheme: HIGTheme, Sendable { public let navigationBar: any HIGNavigationBarTokens public let toolbar: any HIGToolbarTokens public let activityIndicator: any HIGActivityIndicatorTokens + public let shimmer: any HIGShimmerTokens public let list: any HIGListTokens public let sidebar: any HIGSidebarTokens public let photoPicker: any HIGPhotoPickerTokens @@ -84,6 +85,7 @@ public struct HIGHighContrastTheme: HIGTheme, Sendable { self.navigationBar = base.navigationBar self.toolbar = base.toolbar self.activityIndicator = base.activityIndicator + self.shimmer = base.shimmer self.list = base.list self.sidebar = base.sidebar self.photoPicker = base.photoPicker diff --git a/Sources/HIGThemesSystem/HIGSystemTheme.swift b/Sources/HIGThemesSystem/HIGSystemTheme.swift index 1b5015d..9257c4c 100644 --- a/Sources/HIGThemesSystem/HIGSystemTheme.swift +++ b/Sources/HIGThemesSystem/HIGSystemTheme.swift @@ -35,6 +35,7 @@ public struct HIGSystemTheme: HIGTheme, Sendable { public let navigationBar: any HIGNavigationBarTokens public let toolbar: any HIGToolbarTokens public let activityIndicator: any HIGActivityIndicatorTokens + public let shimmer: any HIGShimmerTokens public let list: any HIGListTokens public let sidebar: any HIGSidebarTokens public let photoPicker: any HIGPhotoPickerTokens @@ -71,6 +72,7 @@ public struct HIGSystemTheme: HIGTheme, Sendable { navigationBar: any HIGNavigationBarTokens = HIGSystemNavigationBarTokens(), toolbar: any HIGToolbarTokens = HIGSystemToolbarTokens(), activityIndicator: any HIGActivityIndicatorTokens = HIGSystemActivityIndicatorTokens(), + shimmer: any HIGShimmerTokens = HIGSystemShimmerTokens(), list: any HIGListTokens = HIGSystemListTokens(), sidebar: any HIGSidebarTokens = HIGSystemSidebarTokens(), photoPicker: any HIGPhotoPickerTokens = HIGSystemPhotoPickerTokens() @@ -106,6 +108,7 @@ public struct HIGSystemTheme: HIGTheme, Sendable { self.navigationBar = navigationBar self.toolbar = toolbar self.activityIndicator = activityIndicator + self.shimmer = shimmer self.list = list self.sidebar = sidebar self.photoPicker = photoPicker diff --git a/Sources/HIGTokensComponent/HIGActivityIndicatorTokens.swift b/Sources/HIGTokensComponent/HIGActivityIndicatorTokens.swift index 8932794..5b56058 100644 --- a/Sources/HIGTokensComponent/HIGActivityIndicatorTokens.swift +++ b/Sources/HIGTokensComponent/HIGActivityIndicatorTokens.swift @@ -5,20 +5,108 @@ public protocol HIGActivityIndicatorTokens: Sendable { var smallScale: CGFloat { get } var mediumScale: CGFloat { get } var largeScale: CGFloat { get } + var customDiameter: CGFloat { get } + var orbitalLineWidth: CGFloat { get } + var pulsingSegmentCount: Int { get } + var pulsingDimmedOpacity: CGFloat { get } + var arcsCount: Int { get } + var arcsSweepDegrees: CGFloat { get } + var arcsRotationSpeed: Double { get } + var rotatingDotsCount: Int { get } + var flickeringDotsCount: Int { get } + var scalingDotsCount: Int { get } + var scalingDotsInset: CGFloat { get } + var scalingDotsMinScale: CGFloat { get } + var opacityDotsCount: Int { get } + var opacityDotsInset: CGFloat { get } + var opacityDotsMinScale: CGFloat { get } + var opacityDotsMinOpacity: CGFloat { get } + var equalizerBarCount: Int { get } + var equalizerBarCornerRadius: CGFloat { get } + var equalizerMinScale: CGFloat { get } + var growingCircleFadeOpacity: CGFloat { get } + var gradientTrimLeading: CGFloat { get } + var gradientTrimTrailing: CGFloat { get } } public struct HIGSystemActivityIndicatorTokens: HIGActivityIndicatorTokens, Sendable { public let smallScale: CGFloat public let mediumScale: CGFloat public let largeScale: CGFloat + public let customDiameter: CGFloat + public let orbitalLineWidth: CGFloat + public let pulsingSegmentCount: Int + public let pulsingDimmedOpacity: CGFloat + public let arcsCount: Int + public let arcsSweepDegrees: CGFloat + public let arcsRotationSpeed: Double + public let rotatingDotsCount: Int + public let flickeringDotsCount: Int + public let scalingDotsCount: Int + public let scalingDotsInset: CGFloat + public let scalingDotsMinScale: CGFloat + public let opacityDotsCount: Int + public let opacityDotsInset: CGFloat + public let opacityDotsMinScale: CGFloat + public let opacityDotsMinOpacity: CGFloat + public let equalizerBarCount: Int + public let equalizerBarCornerRadius: CGFloat + public let equalizerMinScale: CGFloat + public let growingCircleFadeOpacity: CGFloat + public let gradientTrimLeading: CGFloat + public let gradientTrimTrailing: CGFloat public init( smallScale: CGFloat = 0.85, mediumScale: CGFloat = 1, - largeScale: CGFloat = 1.25 + largeScale: CGFloat = 1.25, + customDiameter: CGFloat = 36, + orbitalLineWidth: CGFloat = 3, + pulsingSegmentCount: Int = 8, + pulsingDimmedOpacity: CGFloat = 0.3, + arcsCount: Int = 3, + arcsSweepDegrees: CGFloat = 180, + arcsRotationSpeed: Double = 0.35, + rotatingDotsCount: Int = 5, + flickeringDotsCount: Int = 8, + scalingDotsCount: Int = 3, + scalingDotsInset: CGFloat = 2, + scalingDotsMinScale: CGFloat = 0.3, + opacityDotsCount: Int = 3, + opacityDotsInset: CGFloat = 4, + opacityDotsMinScale: CGFloat = 0.9, + opacityDotsMinOpacity: CGFloat = 0.3, + equalizerBarCount: Int = 5, + equalizerBarCornerRadius: CGFloat = 3, + equalizerMinScale: CGFloat = 0.4, + growingCircleFadeOpacity: CGFloat = 0, + gradientTrimLeading: CGFloat = 0.006, + gradientTrimTrailing: CGFloat = 0.01 ) { self.smallScale = smallScale self.mediumScale = mediumScale self.largeScale = largeScale + self.customDiameter = customDiameter + self.orbitalLineWidth = orbitalLineWidth + self.pulsingSegmentCount = pulsingSegmentCount + self.pulsingDimmedOpacity = pulsingDimmedOpacity + self.arcsCount = arcsCount + self.arcsSweepDegrees = arcsSweepDegrees + self.arcsRotationSpeed = arcsRotationSpeed + self.rotatingDotsCount = rotatingDotsCount + self.flickeringDotsCount = flickeringDotsCount + self.scalingDotsCount = scalingDotsCount + self.scalingDotsInset = scalingDotsInset + self.scalingDotsMinScale = scalingDotsMinScale + self.opacityDotsCount = opacityDotsCount + self.opacityDotsInset = opacityDotsInset + self.opacityDotsMinScale = opacityDotsMinScale + self.opacityDotsMinOpacity = opacityDotsMinOpacity + self.equalizerBarCount = equalizerBarCount + self.equalizerBarCornerRadius = equalizerBarCornerRadius + self.equalizerMinScale = equalizerMinScale + self.growingCircleFadeOpacity = growingCircleFadeOpacity + self.gradientTrimLeading = gradientTrimLeading + self.gradientTrimTrailing = gradientTrimTrailing } } \ No newline at end of file diff --git a/Sources/HIGTokensComponent/HIGShimmerTokens.swift b/Sources/HIGTokensComponent/HIGShimmerTokens.swift new file mode 100644 index 0000000..45f1d11 --- /dev/null +++ b/Sources/HIGTokensComponent/HIGShimmerTokens.swift @@ -0,0 +1,32 @@ +import CoreGraphics +import Foundation + +public protocol HIGShimmerTokens: Sendable { + var bandSize: CGFloat { get } + var highlightOpacity: CGFloat { get } + var baseOpacity: CGFloat { get } + var animationDuration: TimeInterval { get } + var animationDelay: TimeInterval { get } +} + +public struct HIGSystemShimmerTokens: HIGShimmerTokens, Sendable { + public let bandSize: CGFloat + public let highlightOpacity: CGFloat + public let baseOpacity: CGFloat + public let animationDuration: TimeInterval + public let animationDelay: TimeInterval + + public init( + bandSize: CGFloat = 0.3, + highlightOpacity: CGFloat = 1, + baseOpacity: CGFloat = 0.3, + animationDuration: TimeInterval = 1.5, + animationDelay: TimeInterval = 0.25 + ) { + self.bandSize = bandSize + self.highlightOpacity = highlightOpacity + self.baseOpacity = baseOpacity + self.animationDuration = animationDuration + self.animationDelay = animationDelay + } +} \ No newline at end of file diff --git a/Tests/HIGDesignTests/HIGActivityIndicatorTokensTests.swift b/Tests/HIGDesignTests/HIGActivityIndicatorTokensTests.swift index 3156999..a6fcac1 100644 --- a/Tests/HIGDesignTests/HIGActivityIndicatorTokensTests.swift +++ b/Tests/HIGDesignTests/HIGActivityIndicatorTokensTests.swift @@ -6,4 +6,22 @@ func activityIndicatorTokensDefineScaleProgression() { let tokens = HIGSystemActivityIndicatorTokens() #expect(tokens.smallScale < tokens.mediumScale) #expect(tokens.mediumScale < tokens.largeScale) +} + +@Test +func activityIndicatorTokensDefineCustomStyleMetrics() { + let tokens = HIGSystemActivityIndicatorTokens() + #expect(tokens.customDiameter > 0) + #expect(tokens.orbitalLineWidth > 0) + #expect(tokens.pulsingSegmentCount > 0) + #expect(tokens.pulsingDimmedOpacity > 0) + #expect(tokens.pulsingDimmedOpacity < 1) + #expect(tokens.arcsCount > 0) + #expect(tokens.rotatingDotsCount > 0) + #expect(tokens.flickeringDotsCount > 0) + #expect(tokens.scalingDotsCount > 0) + #expect(tokens.opacityDotsCount > 0) + #expect(tokens.equalizerBarCount > 0) + #expect(tokens.gradientTrimLeading >= 0) + #expect(tokens.gradientTrimTrailing >= 0) } \ No newline at end of file diff --git a/Tests/HIGDesignTests/HIGShimmerTokensTests.swift b/Tests/HIGDesignTests/HIGShimmerTokensTests.swift new file mode 100644 index 0000000..2ce5768 --- /dev/null +++ b/Tests/HIGDesignTests/HIGShimmerTokensTests.swift @@ -0,0 +1,11 @@ +import HIGTokensComponent +import Testing + +@Test +func shimmerTokensDefineVisibleBandAndTiming() { + let tokens = HIGSystemShimmerTokens() + #expect(tokens.bandSize > 0) + #expect(tokens.highlightOpacity > tokens.baseOpacity) + #expect(tokens.animationDuration > 0) + #expect(tokens.animationDelay >= 0) +} \ No newline at end of file