diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e33412..a80af15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,11 @@ on: jobs: build: - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app + - name: Show Xcode version + run: xcodebuild -version - name: Validate package run: swift package dump-package - name: Build package for iOS Simulator @@ -21,7 +21,7 @@ jobs: - name: Test package run: >- xcodebuild -scheme Aither - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest' + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest' test - name: Build preview application run: >- diff --git a/README.md b/README.md index 4eee7e8..aaa6e94 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Aither -Aither is a dependency-free UIKit package for expressive, continuously looping -particle orbs. Every animation is rendered with Core Graphics, works without -SwiftUI, and can respond smoothly to live application data. +Aither is a dependency-free package for expressive, continuously looping +particle orbs. Its primary API is native SwiftUI, backed by an efficient +UIKit/Core Graphics renderer that can also be used directly. ## Requirements @@ -27,15 +27,72 @@ The package has no third-party dependencies. ## Quick start ```swift +import SwiftUI import Aither +AitherOrb() + .aitherAnimation(.aither) + .aitherColors(.cyan, .purple) + .aitherSpeed(1.2) + .aitherIntensity(audioLevel) + .frame(width: 96, height: 96) +``` + +`aitherIntensity(_:)` accepts a normalized live value such as an audio level or +network activity. When SwiftUI state changes, Aither updates the existing +renderer instead of recreating it. + +## Data-driven motion + +Drive motion directly from SwiftUI state: + +```swift +AitherOrb() + .aitherAnimation(.synthesis) + .aitherColors(.cyan, .indigo) + .aitherIntensity(audioLevel) + .aitherProgress(uploadProgress) + .aitherEmphasis(notificationStrength) + .aitherStatus(isComplete ? .success : .active) + .frame(width: 96, height: 96) +``` + +All normalized inputs are clamped to `0...1` by the renderer. Changes are +interpolated by default, so noisy data does not produce visual jitter. + +## SwiftUI configuration + +| Modifier | Purpose | +| --- | --- | +| `aitherAnimation(_:)` | Selects a continuously looping motion system. | +| `aitherColors(_:_:)` | Sets a depth-aware one- or two-color palette. | +| `aitherSpeed(_:)` | Sets the base playback-rate multiplier. | +| `aitherIntensity(_:)` | Drives motion with normalized live activity data. | +| `aitherProgress(_:)` | Supplies optional normalized progress. | +| `aitherEmphasis(_:)` | Supplies a normalized transient emphasis value. | +| `aitherStatus(_:)` | Sets idle, active, success, warning, or failure state. | +| `aitherParticleIntensity(_:)` | Controls particle contrast and opacity. | +| `aitherDensity(_:)` | Balances particle detail and rendering cost. | +| `aitherGlow(_:)` | Controls foreground particle halos. | +| `aitherDirection(_:)` | Selects forward or reverse playback. | +| `aitherPhaseOffset(_:)` | Desynchronizes multiple orb instances. | +| `aitherPhase(_:)` | Supplies an external phase; `nil` restores looping. | +| `aitherResponse(_:)` | Sets the live-data interpolation duration. | +| `aitherEasing(_:)` | Selects instant, smooth, spring, or cubic Bézier response. | +| `aitherQuality(_:)` | Selects automatic, low, or high rendering quality. | +| `aitherActivityMapping(_:)` | Maps activity to selected visual properties. | +| `aitherPaused(_:)` | Pauses or resumes playback from SwiftUI state. | + +## UIKit + +`AitherView` remains available for UIKit applications and advanced imperative +integration: + +```swift let orb = AitherView() .animation(.energeia) .palette(primary: .systemCyan, secondary: .systemPurple) .speed(1.2) - .intensity(0.9) - .density(0.85) - .glow(0.6) orb.translatesAutoresizingMaskIntoConstraints = false view.addSubview(orb) @@ -48,20 +105,7 @@ NSLayoutConstraint.activate([ ]) ``` -`AitherView` is a regular `UIView`; size and layout remain under Auto Layout or -frame-based control. - -## Data-driven motion - -Update live values independently: - -```swift -orb.setActivity(networkActivity) -orb.setProgress(uploadProgress) -orb.setEmphasis(notificationStrength) -``` - -Or apply one coherent input snapshot: +Apply one coherent UIKit input snapshot: ```swift orb.update( @@ -73,8 +117,7 @@ orb.update( ) ``` -All normalized inputs are clamped to `0...1`. Changes are interpolated by -default so noisy data does not produce visual jitter: +Configure how UIKit input is interpolated: ```swift orb @@ -116,7 +159,7 @@ orb.stopAnimating() Available statuses are `idle`, `active`, `success`, `warning`, and `failure`. -## Configuration +## UIKit configuration | API | Purpose | | --- | --- | diff --git a/Sources/Aither/AitherOrb.swift b/Sources/Aither/AitherOrb.swift new file mode 100644 index 0000000..95638ff --- /dev/null +++ b/Sources/Aither/AitherOrb.swift @@ -0,0 +1,254 @@ +import SwiftUI + +/// The motion rendered by an ``AitherOrb``. +public enum AitherAnimation: String, CaseIterable, Equatable, Sendable { + /// The branded default, backed by the Energeia motion system. + case aither, zetesis, lysis, akroasis, synthesis, morphosis + case periphora, diodos, krystallos, systole + + var state: AitherView.State { + switch self { + case .aither: .energeia + case .zetesis: .zetesis + case .lysis: .lysis + case .akroasis: .akroasis + case .synthesis: .synthesis + case .morphosis: .morphosis + case .periphora: .periphora + case .diodos: .diodos + case .krystallos: .krystallos + case .systole: .systole + } + } +} + +/// A SwiftUI particle orb backed by Aither's UIKit/Core Graphics renderer. +public struct AitherOrb: UIViewRepresentable { + struct Configuration: Equatable { + var animation: AitherAnimation = .aither + var primaryColor: Color = .primary + var secondaryColor: Color? + var speed: CGFloat = 1 + var input = AitherView.OrbInput() + var particleIntensity: CGFloat = 1 + var density: CGFloat = 1 + var glow: CGFloat = 0.6 + var direction: AitherView.Direction = .forward + var phaseOffset: CGFloat = 0 + var phase: CGFloat? + var quality: AitherView.Quality = .automatic + var status: AitherView.Status = .active + var reaction: AitherView.Reaction = .smooth(duration: 0.25) + var activityEffects: AitherView.DataEffects = .all + var isPaused = false + } + + final public class Coordinator { + fileprivate var hasAppliedConfiguration = false + fileprivate var lastConfiguration: Configuration? + fileprivate var lastInput: AitherView.OrbInput? + fileprivate var lastStatus: AitherView.Status? + fileprivate var lastPhase: CGFloat? + fileprivate var hadExternalPhase = false + } + + var configuration = Configuration() + + public init() {} + + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + public func makeUIView(context: Context) -> AitherView { + let view = AitherView() + applyConfiguration(to: view, coordinator: context.coordinator) + return view + } + + public func updateUIView(_ uiView: AitherView, context: Context) { + applyConfiguration(to: uiView, coordinator: context.coordinator) + } + + public static func dismantleUIView(_ uiView: AitherView, coordinator: Coordinator) { + uiView.pauseAnimating() + } + + /// Selects one of Aither's continuously looping motion systems. + public func aitherAnimation(_ animation: AitherAnimation) -> Self { + configured { $0.animation = animation } + } + + /// Sets a one- or two-color depth-aware particle palette. + public func aitherColors(_ primary: Color, _ secondary: Color? = nil) -> Self { + configured { + $0.primaryColor = primary + $0.secondaryColor = secondary + } + } + + /// Sets the base playback-rate multiplier. Values are clamped to 0.05...4. + public func aitherSpeed(_ multiplier: CGFloat) -> Self { + configured { $0.speed = multiplier } + } + + /// Drives the orb with a normalized live activity value, such as an audio level. + public func aitherIntensity(_ value: CGFloat) -> Self { + configured { $0.input.activity = value } + } + + /// Drives the orb with optional normalized progress. + public func aitherProgress(_ value: CGFloat?) -> Self { + configured { $0.input.progress = value } + } + + /// Adds a normalized transient emphasis value. + public func aitherEmphasis(_ value: CGFloat) -> Self { + configured { $0.input.emphasis = value } + } + + /// Sets the semantic state and its corresponding visual tint. + public func aitherStatus(_ status: AitherView.Status) -> Self { + configured { $0.status = status } + } + + /// Controls particle contrast and opacity independently of live intensity data. + public func aitherParticleIntensity(_ value: CGFloat) -> Self { + configured { $0.particleIntensity = value } + } + + /// Sets the normalized amount of visible particles. + public func aitherDensity(_ value: CGFloat) -> Self { + configured { $0.density = value } + } + + /// Sets the normalized foreground halo strength. + public func aitherGlow(_ value: CGFloat) -> Self { + configured { $0.glow = value } + } + + public func aitherDirection(_ direction: AitherView.Direction) -> Self { + configured { $0.direction = direction } + } + + public func aitherPhaseOffset(_ offset: CGFloat) -> Self { + configured { $0.phaseOffset = offset } + } + + /// Sets an externally controlled normalized phase. Pass nil for autonomous looping. + public func aitherPhase(_ phase: CGFloat?) -> Self { + configured { $0.phase = phase } + } + + public func aitherQuality(_ quality: AitherView.Quality) -> Self { + configured { $0.quality = quality } + } + + public func aitherResponse(_ duration: TimeInterval) -> Self { + configured { $0.reaction = .smooth(duration: duration) } + } + + public func aitherEasing(_ reaction: AitherView.Reaction) -> Self { + configured { $0.reaction = reaction } + } + + public func aitherActivityMapping(_ effects: AitherView.DataEffects) -> Self { + configured { $0.activityEffects = effects } + } + + public func aitherPaused(_ isPaused: Bool) -> Self { + configured { $0.isPaused = isPaused } + } + + private func configured(_ update: (inout Configuration) -> Void) -> Self { + var copy = self + update(©.configuration) + return copy + } + + private func applyConfiguration(to view: AitherView, coordinator: Coordinator) { + let previous = coordinator.lastConfiguration + + if previous?.animation != configuration.animation { + view.animation(configuration.animation.state) + } + if previous?.primaryColor != configuration.primaryColor + || previous?.secondaryColor != configuration.secondaryColor { + view.palette( + primary: UIColor(configuration.primaryColor), + secondary: configuration.secondaryColor.map(UIColor.init) + ) + } + if previous?.speed != configuration.speed { + view.speed(configuration.speed) + } + if previous?.particleIntensity != configuration.particleIntensity { + view.intensity(configuration.particleIntensity) + } + if previous?.density != configuration.density { + view.density(configuration.density) + } + if previous?.glow != configuration.glow { + view.glow(configuration.glow) + } + if previous?.direction != configuration.direction { + view.direction(configuration.direction) + } + if previous?.phaseOffset != configuration.phaseOffset { + view.phaseOffset(configuration.phaseOffset) + } + if previous?.quality != configuration.quality { + view.quality(configuration.quality) + } + if previous?.reaction != configuration.reaction { + view.easing(configuration.reaction) + } + if previous?.activityEffects != configuration.activityEffects { + view.mapActivity(to: configuration.activityEffects) + } + if previous?.isPaused != configuration.isPaused { + view.isPaused = configuration.isPaused + } + + let animated = coordinator.hasAppliedConfiguration + let statusChanged = coordinator.lastStatus != configuration.status + if statusChanged { + view.setStatus(configuration.status, animated: animated) + coordinator.lastStatus = configuration.status + } + + let input = resolvedInput(for: configuration) + if statusChanged || coordinator.lastInput != input { + view.update(input, animated: animated) + coordinator.lastInput = input + } + + if !coordinator.hadExternalPhase || coordinator.lastPhase != configuration.phase { + view.setPhase(configuration.phase) + coordinator.lastPhase = configuration.phase + coordinator.hadExternalPhase = true + } + + coordinator.lastConfiguration = configuration + coordinator.hasAppliedConfiguration = true + } + + private func resolvedInput(for configuration: Configuration) -> AitherView.OrbInput { + var input = configuration.input + switch configuration.status { + case .idle: + input.activity = min(input.activity, 0.08) + input.emphasis = 0 + case .active: + break + case .success: + input.progress = input.progress ?? 1 + input.emphasis = max(input.emphasis, 0.8) + case .warning: + input.emphasis = max(input.emphasis, 0.65) + case .failure: + input.emphasis = 1 + } + return input + } +} diff --git a/Sources/Aither/AitherView.swift b/Sources/Aither/AitherView.swift index c72f188..ca388cb 100644 --- a/Sources/Aither/AitherView.swift +++ b/Sources/Aither/AitherView.swift @@ -4,7 +4,7 @@ import Combine #endif public final class AitherView: UIView { - public enum State: String, CaseIterable { + public enum State: String, CaseIterable, Equatable, Sendable { case energeia, zetesis, lysis, akroasis, synthesis, morphosis case periphora, diodos, krystallos, systole @@ -24,20 +24,20 @@ public final class AitherView: UIView { } } - public enum Direction { + public enum Direction: Equatable, Sendable { case forward case reverse } - public enum Status: String { + public enum Status: String, Equatable, Sendable { case idle, active, success, warning, failure } - public enum Quality { + public enum Quality: Equatable, Sendable { case automatic, low, high } - public enum Reaction { + public enum Reaction: Equatable, Sendable { case instant case smooth(duration: TimeInterval) case spring(response: TimeInterval, damping: CGFloat) @@ -57,7 +57,7 @@ public final class AitherView: UIView { public static let all: DataEffects = [.speed, .deformation, .glow, .density, .color] } - public struct OrbInput: Sendable { + public struct OrbInput: Equatable, Sendable { public var activity: CGFloat public var progress: CGFloat? public var emphasis: CGFloat diff --git a/Tests/AitherTests/AitherTests.swift b/Tests/AitherTests/AitherTests.swift index bf1d703..9d2ad8b 100644 --- a/Tests/AitherTests/AitherTests.swift +++ b/Tests/AitherTests/AitherTests.swift @@ -1,4 +1,6 @@ import XCTest +import SwiftUI + @testable import Aither @MainActor @@ -67,4 +69,41 @@ final class AitherTests: XCTestCase { XCTAssertEqual(view.emphasis, 0.1) task.cancel() } + + func testSwiftUIOrbModifiersBuildExpectedConfiguration() { + let orb = AitherOrb() + .aitherAnimation(.synthesis) + .aitherColors(.cyan, .purple) + .aitherSpeed(1.2) + .aitherIntensity(0.72) + .aitherProgress(0.4) + .aitherEmphasis(0.15) + .aitherStatus(.warning) + .aitherQuality(.high) + .aitherPaused(true) + + XCTAssertEqual(orb.configuration.animation.rawValue, "synthesis") + XCTAssertEqual(orb.configuration.speed, 1.2) + XCTAssertEqual(orb.configuration.input.activity, 0.72) + XCTAssertEqual(orb.configuration.input.progress, 0.4) + XCTAssertEqual(orb.configuration.input.emphasis, 0.15) + XCTAssertEqual(orb.configuration.status, .warning) + XCTAssertTrue(orb.configuration.isPaused) + } + + func testBrandedAnimationUsesEnergeiaRenderer() { + XCTAssertEqual(AitherAnimation.aither.state, .energeia) + } + + func testSwiftUIQuickStartCompositionCompiles() { + let audioLevel: CGFloat = 0.65 + let view = AitherOrb() + .aitherAnimation(.aither) + .aitherColors(.cyan, .purple) + .aitherSpeed(1.2) + .aitherIntensity(audioLevel) + .frame(width: 96, height: 96) + + XCTAssertNotNil(view) + } }