From 3ece45d2151478a14454b1ea0e097c26a23e54de Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 1 Feb 2026 13:54:52 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 8 ++ README.md | 2 + .../Features/Clock/Models/ClockStyle.swift | 8 ++ .../Features/Clock/State/ClockViewModel.swift | 1 + .../Components/ClockDisplayContainer.swift | 3 +- .../Clock/Views/Components/ColonView.swift | 6 ++ .../Clock/Views/Components/DigitView.swift | 98 ++++++++++++++++++- .../Settings/AdvancedAppearanceSection.swift | 15 +++ .../Views/Components/TimeDisplayView.swift | 16 +-- .../Clock/Views/Components/TimeSegment.swift | 7 +- .../Animations/DigitAnimationStyle.swift | 25 +++++ .../Shared/Design/Fonts/FontUtils.swift | 9 +- 12 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 TheNoiseClock/Shared/Design/Animations/DigitAnimationStyle.swift diff --git a/PRD.md b/PRD.md index 5c9abc8..9980ab6 100644 --- a/PRD.md +++ b/PRD.md @@ -22,8 +22,16 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad - **Full-screen mode** with status bar hiding and tab bar expansion - **Orientation-aware spacing** for optimal layout in all orientations +- **Modern iOS 18+ Animations**: + - **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch. + - **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles). + - **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change. + - **Glitch Effect**: High-energy digital jitter with random offsets and rapid opacity shifts. + - **Dynamic Glow Pulsing**: Glow intensity and blur radius pulse during digit transitions for enhanced visual feedback. + - **Breathing Colon Effect**: Subtle opacity pulsing for colon separators to add life to the display. ### 2. Clock Customization +- **Selectable digit animation styles**: Choose from None, Spring, Bounce, and Glitch - **Color customization**: User-selectable digit colors with color picker - **Background color**: Customizable background with color picker - **Glow effects**: Adjustable glow intensity (0-100%) diff --git a/README.md b/README.md index d80b92c..b0d1995 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - Custom fonts, weights, and designs with live preview - Glow and opacity controls for low-light comfort - Clock tab hides the status bar for a distraction-free display +- Selectable animation styles: None, Spring, Bounce, and Glitch +- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons **White Noise** - Multiple ambient categories and curated sound packs diff --git a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift index 1f8686c..3cd0551 100644 --- a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift +++ b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift @@ -41,6 +41,7 @@ class ClockStyle: Codable, Equatable { var fontFamily: FontFamily = .system var fontWeight: Font.Weight = .bold var fontDesign: Font.Design = .rounded + var digitAnimationStyle: DigitAnimationStyle = .spring // MARK: - Overlay Settings var showBattery: Bool = true @@ -78,6 +79,7 @@ class ClockStyle: Codable, Equatable { case fontFamily case fontWeight case fontDesign + case digitAnimationStyle case showBattery case showDate case dateFormat @@ -125,6 +127,10 @@ class ClockStyle: Codable, Equatable { let decoded = Font.Design(rawValue: fontDesignString) { self.fontDesign = decoded } + if let animationStyleRaw = try container.decodeIfPresent(String.self, forKey: .digitAnimationStyle), + let decoded = DigitAnimationStyle(rawValue: animationStyleRaw) { + self.digitAnimationStyle = decoded + } self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat @@ -157,6 +163,7 @@ class ClockStyle: Codable, Equatable { try container.encode(fontFamily.rawValue, forKey: .fontFamily) try container.encode(fontWeight.rawValue, forKey: .fontWeight) try container.encode(fontDesign.rawValue, forKey: .fontDesign) + try container.encode(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle) try container.encode(showBattery, forKey: .showBattery) try container.encode(showDate, forKey: .showDate) try container.encode(dateFormat, forKey: .dateFormat) @@ -449,6 +456,7 @@ class ClockStyle: Codable, Equatable { lhs.fontFamily == rhs.fontFamily && lhs.fontWeight == rhs.fontWeight && lhs.fontDesign == rhs.fontDesign && + lhs.digitAnimationStyle == rhs.digitAnimationStyle && lhs.showBattery == rhs.showBattery && lhs.showDate == rhs.showDate && lhs.dateFormat == rhs.dateFormat && diff --git a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift index db953f1..f24a34f 100644 --- a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift +++ b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift @@ -105,6 +105,7 @@ class ClockViewModel { style.nightModeEndTime = newStyle.nightModeEndTime style.ambientLightThreshold = newStyle.ambientLightThreshold style.autoBrightness = newStyle.autoBrightness + style.digitAnimationStyle = newStyle.digitAnimationStyle style.dateFormat = newStyle.dateFormat style.respectFocusModes = newStyle.respectFocusModes diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift index f883203..1417c4a 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift @@ -36,7 +36,8 @@ struct ClockDisplayContainer: View { fontWeight: style.fontWeight, fontDesign: style.fontDesign, forceHorizontalMode: style.forceHorizontalMode, - isDisplayMode: isDisplayMode + isDisplayMode: isDisplayMode, + animationStyle: style.digitAnimationStyle ) .padding(.top, topSpacing) .frame(width: geometry.size.width, height: geometry.size.height) diff --git a/TheNoiseClock/Features/Clock/Views/Components/ColonView.swift b/TheNoiseClock/Features/Clock/Views/Components/ColonView.swift index 8fe7733..1a81aa7 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ColonView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ColonView.swift @@ -35,6 +35,12 @@ struct ColonView: View { } .fixedSize(horizontal: true, vertical: true) .accessibilityHidden(true) + .phaseAnimator([0, 1]) { content, phase in + content + .opacity(phase == 1 ? 1.0 : 0.6) + } animation: { _ in + .easeInOut(duration: 1.0) + } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift b/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift index 7b331ed..05119ea 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift @@ -22,6 +22,7 @@ struct DigitView: View { let digitColor: Color let glowIntensity: Double let isDisplayMode: Bool + let animationStyle: DigitAnimationStyle @Binding var fontSize: CGFloat init(digit: String, @@ -32,7 +33,8 @@ struct DigitView: View { opacity: Double = 1, glowIntensity: Double = 0, fontSize: Binding, - isDisplayMode: Bool = false) { + isDisplayMode: Bool = false, + animationStyle: DigitAnimationStyle = .spring) { self.digit = (digit.count == 1 && "0123456789".contains(digit)) ? digit : "0" self.fontName = fontName self.weight = weight @@ -41,6 +43,7 @@ struct DigitView: View { self.digitColor = digitColor self.glowIntensity = glowIntensity self.isDisplayMode = isDisplayMode + self.animationStyle = animationStyle self._fontSize = fontSize } @@ -65,12 +68,14 @@ struct DigitView: View { .foregroundColor(digitColor) .blur(radius: glowRadius) .opacity(glowOpacity) + .modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity)) } private var mainText: some View { baseText .foregroundColor(digitColor) .opacity(opacity) + .modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize)) .debugBorder(Self.debugShowBorders, color: .orange, label: "Text") } @@ -84,6 +89,85 @@ struct DigitView: View { } } +// MARK: - Animation Modifiers + +private struct DigitAnimationModifier: ViewModifier { + let style: DigitAnimationStyle + let digit: String + let fontSize: CGFloat + + func body(content: Content) -> some View { + switch style { + case .none: + content + case .spring: + content + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.35), value: digit) + .phaseAnimator([0, 1], trigger: digit) { content, phase in + content + .scaleEffect(phase == 1 ? 1.05 : 1.0) + .offset(y: phase == 1 ? -fontSize * 0.02 : 0) + } animation: { _ in + .spring(duration: 0.3, bounce: 0.4) + } + case .bounce: + content + .contentTransition(.numericText()) + .animation(.bouncy(duration: 0.4), value: digit) + .phaseAnimator([0, 1], trigger: digit) { content, phase in + content + .scaleEffect(phase == 1 ? 1.1 : 1.0) + } animation: { _ in + .spring(duration: 0.4, bounce: 0.5) + } + case .glitch: + content + .contentTransition(.numericText()) + .phaseAnimator([0, 1, 2, 0], trigger: digit) { content, phase in + content + .offset(x: phase == 1 ? -fontSize * 0.05 : (phase == 2 ? fontSize * 0.05 : 0)) + .opacity(phase == 1 || phase == 2 ? 0.7 : 1.0) + .scaleEffect(phase == 1 ? 1.02 : (phase == 2 ? 0.98 : 1.0)) + } animation: { _ in + .interactiveSpring(response: 0.1, dampingFraction: 0.8) + } + } + } +} + +private struct GlowAnimationModifier: ViewModifier { + let style: DigitAnimationStyle + let digit: String + let glowRadius: CGFloat + let glowOpacity: Double + + func body(content: Content) -> some View { + switch style { + case .none: + content + case .spring, .bounce: + content + .phaseAnimator([0, 1], trigger: digit) { content, phase in + content + .blur(radius: glowRadius * (phase == 1 ? 1.5 : 1.0)) + .opacity(glowOpacity * (phase == 1 ? 1.2 : 1.0)) + } animation: { _ in + .easeInOut(duration: 0.3) + } + case .glitch: + content + .phaseAnimator([0, 1, 2, 0], trigger: digit) { content, phase in + content + .opacity(phase == 1 || phase == 2 ? 0.4 : 1.0) + .blur(radius: glowRadius * (phase == 1 || phase == 2 ? 2.0 : 1.0)) + } animation: { _ in + .easeInOut(duration: 0.1) + } + } + } +} + // MARK: - Preview #Preview { @Previewable @State var sharedFontSize: CGFloat = 2000 @@ -98,7 +182,8 @@ struct DigitView: View { weight: weight, design: design, glowIntensity: glowIntensity, - fontSize: $sharedFontSize) + fontSize: $sharedFontSize, + animationStyle: .spring) .border(Color.black) DigitView(digit: "1", @@ -106,7 +191,8 @@ struct DigitView: View { weight: weight, design: design, glowIntensity: glowIntensity, - fontSize: $sharedFontSize) + fontSize: $sharedFontSize, + animationStyle: .spring) .border(Color.black) Text(":") @@ -118,7 +204,8 @@ struct DigitView: View { weight: weight, design: design, glowIntensity: glowIntensity, - fontSize: $sharedFontSize) + fontSize: $sharedFontSize, + animationStyle: .spring) .border(Color.black) DigitView(digit: "5", @@ -126,7 +213,8 @@ struct DigitView: View { weight: weight, design: design, glowIntensity: glowIntensity, - fontSize: $sharedFontSize) + fontSize: $sharedFontSize, + animationStyle: .spring) .border(Color.black) } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift index 578b349..59af5d6 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift @@ -20,6 +20,21 @@ struct AdvancedAppearanceSection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text("Digit Animation") + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + + Picker("Digit Animation", selection: $style.digitAnimationStyle) { + ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in + Text(animation.displayName).tag(animation) + } + } + .pickerStyle(.menu) + .tint(AppAccent.primary) + } + .padding(.vertical, Design.Spacing.xSmall) + SettingsToggle( title: "Randomize Color", subtitle: "Shift the color every minute", diff --git a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift index 8a139bc..948407d 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift @@ -36,6 +36,7 @@ struct TimeDisplayView: View { let fontDesign: Font.Design let forceHorizontalMode: Bool let isDisplayMode: Bool + let animationStyle: DigitAnimationStyle @State var fontSize: CGFloat = 100 @State private var lastCalculatedContainerSize: CGSize = .zero @@ -119,12 +120,12 @@ struct TimeDisplayView: View { return Group { if portrait { VStack(alignment: .center, spacing: 0) { - TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode) + TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true) - TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode) + TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) if showSeconds { ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true) - TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode) + TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) } } .frame(maxWidth: .infinity) // Center horizontally in portrait @@ -140,16 +141,16 @@ struct TimeDisplayView: View { let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits HStack(alignment: .center, spacing: 0) { - TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode) + TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) .frame(width: segmentWidth) ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false) .frame(width: dotDiameter) - TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode) + TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) .frame(width: segmentWidth) if showSeconds { ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false) .frame(width: dotDiameter) - TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode) + TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) .frame(width: segmentWidth) } } @@ -338,7 +339,8 @@ struct TimeDisplayView: View { fontWeight: .medium, fontDesign: .default, forceHorizontalMode: false, - isDisplayMode: false + isDisplayMode: false, + animationStyle: .spring ) .background(Color.black) } diff --git a/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift b/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift index 31984f5..8feae45 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift @@ -24,6 +24,7 @@ struct TimeSegment: View { let fontWeight: Font.Weight let fontDesign: Font.Design let isDisplayMode: Bool + let animationStyle: DigitAnimationStyle var body: some View { // Use fixed digit width based on widest digit ("8") for stable layout @@ -45,7 +46,8 @@ struct TimeSegment: View { opacity: clampedOpacity, glowIntensity: glowIntensity, fontSize: $fontSize, - isDisplayMode: isDisplayMode + isDisplayMode: isDisplayMode, + animationStyle: animationStyle ) .frame(width: fixedDigitWidth) // Fixed width for stability .debugBorder(Self.debugShowBorders, color: .red, label: "D\(index)") @@ -72,7 +74,8 @@ struct TimeSegment: View { fontFamily: .system, fontWeight: .regular, fontDesign: .default, - isDisplayMode: true + isDisplayMode: true, + animationStyle: .spring ) .background(Color.black) } diff --git a/TheNoiseClock/Shared/Design/Animations/DigitAnimationStyle.swift b/TheNoiseClock/Shared/Design/Animations/DigitAnimationStyle.swift new file mode 100644 index 0000000..e1da65b --- /dev/null +++ b/TheNoiseClock/Shared/Design/Animations/DigitAnimationStyle.swift @@ -0,0 +1,25 @@ +// +// DigitAnimationStyle.swift +// TheNoiseClock +// +// Created by AI Agent on 2/1/26. +// + +import SwiftUI + +/// Available animation styles for clock digits +public enum DigitAnimationStyle: String, CaseIterable, Codable { + case none + case spring + case bounce + case glitch + + public var displayName: String { + switch self { + case .none: return "None" + case .spring: return "Spring" + case .bounce: return "Bounce" + case .glitch: return "Glitch" + } + } +} diff --git a/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift b/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift index f2487d4..15f670c 100644 --- a/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift +++ b/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift @@ -241,7 +241,8 @@ private struct TestContentView: View { fontWeight: fontWeight, fontDesign: fontDesign, forceHorizontalMode: true, - isDisplayMode: false) + isDisplayMode: false, + animationStyle: .spring) TimeSegment( text: digit, @@ -252,7 +253,8 @@ private struct TestContentView: View { fontFamily: font, // FontFamily fontWeight: fontWeight, fontDesign: fontDesign, - isDisplayMode: false + isDisplayMode: false, + animationStyle: .spring ) } .frame(width: 400, height: 200) @@ -318,7 +320,8 @@ private struct FontSampleCell: View { fontFamily: font, // FontFamily fontWeight: weight, fontDesign: design, - isDisplayMode: false) + isDisplayMode: false, + animationStyle: .spring) .frame(width: 100, height: 100) .border(Color.black) }