Skip to content

Commit 8bc8a09

Browse files
Merge pull request #97 from peterklingelhofer/feature/Optional-hold-animation
feat(swift): Optional hold ripple animation
2 parents cc4b1e4 + dfe877d commit 8bc8a09

13 files changed

Lines changed: 336 additions & 66 deletions

swift/exhale.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@
490490
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
491491
CODE_SIGN_STYLE = Automatic;
492492
COMBINE_HIDPI_IMAGES = YES;
493-
CURRENT_PROJECT_VERSION = 204;
493+
CURRENT_PROJECT_VERSION = 205;
494494
DEAD_CODE_STRIPPING = YES;
495495
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
496496
DEVELOPMENT_TEAM = VZCHHV7VNW;
@@ -505,7 +505,7 @@
505505
"@executable_path/../Frameworks",
506506
);
507507
MACOSX_DEPLOYMENT_TARGET = 11.0;
508-
MARKETING_VERSION = 2.0.4;
508+
MARKETING_VERSION = 2.0.5;
509509
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
510510
PRODUCT_NAME = "$(TARGET_NAME)";
511511
SUPPORTED_PLATFORMS = macosx;
@@ -524,7 +524,7 @@
524524
CODE_SIGN_ENTITLEMENTS = exhale/exhale.entitlements;
525525
CODE_SIGN_STYLE = Automatic;
526526
COMBINE_HIDPI_IMAGES = YES;
527-
CURRENT_PROJECT_VERSION = 204;
527+
CURRENT_PROJECT_VERSION = 205;
528528
DEAD_CODE_STRIPPING = YES;
529529
DEVELOPMENT_ASSET_PATHS = "\"exhale/Preview Content\"";
530530
DEVELOPMENT_TEAM = VZCHHV7VNW;
@@ -539,7 +539,7 @@
539539
"@executable_path/../Frameworks",
540540
);
541541
MACOSX_DEPLOYMENT_TARGET = 11.0;
542-
MARKETING_VERSION = 2.0.4;
542+
MARKETING_VERSION = 2.0.5;
543543
PRODUCT_BUNDLE_IDENTIFIER = peterklingelhofer.exhale;
544544
PRODUCT_NAME = "$(TARGET_NAME)";
545545
SUPPORTED_PLATFORMS = macosx;

swift/exhale/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
205205
randomizedTimingPostInhaleHold: Binding(get: { self.settingsModel.randomizedTimingPostInhaleHold }, set: { self.settingsModel.randomizedTimingPostInhaleHold = $0 }),
206206
randomizedTimingExhale: Binding(get: { self.settingsModel.randomizedTimingExhale }, set: { self.settingsModel.randomizedTimingExhale = $0 }),
207207
randomizedTimingPostExhaleHold: Binding(get: { self.settingsModel.randomizedTimingPostExhaleHold }, set: { self.settingsModel.randomizedTimingPostExhaleHold = $0 }),
208+
holdRippleMode: Binding(get: { self.settingsModel.holdRippleMode }, set: { self.settingsModel.holdRippleMode = $0 }),
208209
isAnimating: Binding(get: { self.settingsModel.isAnimating }, set: { self.settingsModel.isAnimating = $0 }),
209210
appVisibility: Binding(get: { self.settingsModel.appVisibility }, set: { self.settingsModel.appVisibility = $0 }),
210211
reminderIntervalMinutes: Binding(get: { self.settingsModel.reminderIntervalMinutes }, set: { self.settingsModel.reminderIntervalMinutes = $0 }),

swift/exhale/ContentView.swift

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ extension Shape {
7373
}
7474
}
7575

76+
// Traces half the screen perimeter: bottom-center → corner → side → top-center.
77+
// Used for the hold-phase ripple effect. `rightSide: true` goes clockwise (right),
78+
// `rightSide: false` goes counter-clockwise (left).
79+
struct HalfPerimeterShape: Shape {
80+
let rightSide: Bool
81+
82+
func path(in rect: CGRect) -> Path {
83+
var path = Path()
84+
let w = rect.width
85+
let h = rect.height
86+
87+
if rightSide {
88+
path.move(to: CGPoint(x: w / 2, y: h))
89+
path.addLine(to: CGPoint(x: w, y: h))
90+
path.addLine(to: CGPoint(x: w, y: 0))
91+
path.addLine(to: CGPoint(x: w / 2, y: 0))
92+
} else {
93+
path.move(to: CGPoint(x: w / 2, y: h))
94+
path.addLine(to: CGPoint(x: 0, y: h))
95+
path.addLine(to: CGPoint(x: 0, y: 0))
96+
path.addLine(to: CGPoint(x: w / 2, y: 0))
97+
}
98+
99+
return path
100+
}
101+
}
102+
76103
struct ContentView: View {
77104
@EnvironmentObject var settingsModel: SettingsModel
78105
@State private var animationProgress: CGFloat = 0
@@ -82,6 +109,8 @@ struct ContentView: View {
82109
@State private var cycleCount: Int = 0
83110
@State private var cachedMaxCircleScale: CGFloat = 1
84111
@State private var animationSessionIdentifier: Int = 0
112+
@State private var holdProgress: CGFloat = 0
113+
@State private var rippleOpacity: Double = 0
85114
var body: some View {
86115
ZStack {
87116
GeometryReader { geometry in
@@ -151,6 +180,45 @@ struct ContentView: View {
151180
}
152181
}
153182
.opacity(settingsModel.overlayOpacity)
183+
184+
// Screen-edge ripple during hold phases (driven by rippleOpacity)
185+
if settingsModel.holdRippleEnabled && rippleOpacity > 0 {
186+
let isExhale = breathingPhase == .holdAfterExhale
187+
|| (breathingPhase == .inhale && holdProgress > 0)
188+
let phaseColor = isExhale
189+
? settingsModel.cachedExhaleColor
190+
: settingsModel.cachedInhaleColor
191+
let borderUnit = min(geometry.size.width, geometry.size.height) * 0.04
192+
let useGradient = settingsModel.holdRippleMode == .gradient
193+
194+
let trailFrom = isExhale ? holdProgress : 0 as CGFloat
195+
let trailTo = isExhale ? 1 as CGFloat : holdProgress
196+
let bandFrom = isExhale ? holdProgress : max(0, holdProgress - 0.12)
197+
let bandTo = isExhale ? min(1, holdProgress + 0.12) : holdProgress
198+
199+
// When blurred, use a wider stroke so the glow is visible after
200+
// the blur spreads it. The blur softens ALL edges: inner (toward
201+
// center), and leading/trailing (along the perimeter).
202+
let strokeWidth = useGradient ? borderUnit * 3 : borderUnit * 2
203+
let blurRadius = useGradient ? borderUnit * 2 : 0 as CGFloat
204+
205+
Group {
206+
ForEach([true, false], id: \.self) { rightSide in
207+
// Trail glow (fills behind the sweep front)
208+
HalfPerimeterShape(rightSide: rightSide)
209+
.trim(from: trailFrom, to: trailTo)
210+
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
211+
.opacity(0.25)
212+
// Leading band (bright sweep at the front)
213+
HalfPerimeterShape(rightSide: rightSide)
214+
.trim(from: bandFrom, to: bandTo)
215+
.stroke(phaseColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt))
216+
.opacity(0.8)
217+
}
218+
}
219+
.blur(radius: blurRadius)
220+
.opacity(rippleOpacity)
221+
}
154222
}
155223
}
156224
}
@@ -180,6 +248,7 @@ struct ContentView: View {
180248
randomizedTimingPostInhaleHold: $settingsModel.randomizedTimingPostInhaleHold,
181249
randomizedTimingExhale: $settingsModel.randomizedTimingExhale,
182250
randomizedTimingPostExhaleHold: $settingsModel.randomizedTimingPostExhaleHold,
251+
holdRippleMode: $settingsModel.holdRippleMode,
183252
isAnimating: $settingsModel.isAnimating,
184253
appVisibility: $settingsModel.appVisibility,
185254
reminderIntervalMinutes: $settingsModel.reminderIntervalMinutes,
@@ -247,6 +316,11 @@ struct ContentView: View {
247316
? .linear(duration: duration)
248317
: .timingCurve(0.42, 0, 0.58, 1, duration: duration)
249318

319+
// Fade ripple out over the first 10% of the inhale
320+
withAnimation(.linear(duration: duration * 0.1)) {
321+
rippleOpacity = 0
322+
}
323+
250324
withAnimation(animation) {
251325
breathingPhase = .inhale
252326
animationProgress = 1.0
@@ -269,6 +343,13 @@ struct ContentView: View {
269343
}
270344
duration = max(duration, 0.1)
271345
breathingPhase = .holdAfterInhale
346+
if settingsModel.holdRippleEnabled && settingsModel.postInhaleHoldDuration > 0 {
347+
holdProgress = 0
348+
rippleOpacity = 1
349+
withAnimation(.linear(duration: duration)) {
350+
holdProgress = 1
351+
}
352+
}
272353
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
273354
guard currentAnimationSessionIdentifier == self.animationSessionIdentifier else { return }
274355
self.exhale()
@@ -288,6 +369,11 @@ struct ContentView: View {
288369
? .linear(duration: duration)
289370
: .timingCurve(0.42, 0, 0.58, 1, duration: duration)
290371

372+
// Fade ripple out over the first 10% of the exhale
373+
withAnimation(.linear(duration: duration * 0.1)) {
374+
rippleOpacity = 0
375+
}
376+
291377
withAnimation(animation) {
292378
breathingPhase = .exhale
293379
animationProgress = 0.0
@@ -307,7 +393,13 @@ struct ContentView: View {
307393
}
308394
duration = max(duration, 0.1)
309395
breathingPhase = .holdAfterExhale
310-
396+
if settingsModel.holdRippleEnabled && settingsModel.postExhaleHoldDuration > 0 {
397+
holdProgress = 1
398+
rippleOpacity = 1
399+
withAnimation(.linear(duration: duration)) {
400+
holdProgress = 0
401+
}
402+
}
311403
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
312404
guard currentAnimationSessionIdentifier == self.animationSessionIdentifier else { return }
313405
guard self.settingsModel.isAnimating else { return self.resetAnimation() }
@@ -320,14 +412,17 @@ struct ContentView: View {
320412
animationSessionIdentifier += 1
321413
cycleCount = 0
322414
animationProgress = 0.0
415+
holdProgress = 0
416+
rippleOpacity = 0
323417
breathingPhase = .inhale
324418
}
325419

326420
func stopCurrentAnimation() {
327-
// Stop the current animation
328421
animationSessionIdentifier += 1
329422
cycleCount = 0
330423
animationProgress = 0.0
424+
holdProgress = 0
425+
rippleOpacity = 0
331426
}
332427

333428
func resumeBreathingCycle() {

swift/exhale/MetalBreathingController.swift

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import QuartzCore
55
struct MetalBreathingState {
66
var phase: BreathingPhase
77
var progress: Float
8+
var holdTime: Float = 0
89
}
910

1011
final class MetalBreathingController {
@@ -168,20 +169,34 @@ final class MetalBreathingController {
168169
return
169170
}
170171

171-
// Render once when we enter the hold, then sleep until it ends
172-
if !didRenderThisHold {
173-
didRenderThisHold = true
174-
175-
lastDrawRequestTime = now
176-
let state = computeCurrentState(now: now)
177-
lastDrawnPhase = state.phase
178-
lastDrawnProgress = state.progress
179-
shouldDraw = true
172+
// When ripple is enabled, continuously render during holds
173+
if settingsModel.holdRippleEnabled {
174+
let holdCadence = maximumDrawIntervalFast
175+
if now - lastDrawRequestTime >= holdCadence || !didRenderThisHold {
176+
didRenderThisHold = true
177+
lastDrawRequestTime = now
178+
let state = computeCurrentState(now: now)
179+
lastDrawnPhase = state.phase
180+
lastDrawnProgress = state.progress
181+
shouldDraw = true
182+
}
183+
nextInterval = min(holdCadence, remaining)
180184
} else {
181-
shouldDraw = false
185+
// Render once when we enter the hold, then sleep until it ends
186+
if !didRenderThisHold {
187+
didRenderThisHold = true
188+
189+
lastDrawRequestTime = now
190+
let state = computeCurrentState(now: now)
191+
lastDrawnPhase = state.phase
192+
lastDrawnProgress = state.progress
193+
shouldDraw = true
194+
} else {
195+
shouldDraw = false
196+
}
197+
nextInterval = remaining
182198
}
183199

184-
nextInterval = remaining
185200
return
186201
}
187202

@@ -257,16 +272,17 @@ final class MetalBreathingController {
257272
let elapsed = now - phaseStartTime
258273
let rawT = phaseDuration > 0 ? min(max(elapsed / phaseDuration, 0), 1) : 1
259274
let easedT = getEasedT(rawT: rawT)
275+
let holdTime = Float(phaseDuration > 0 ? min(max(elapsed / phaseDuration, 0), 1) : 0)
260276

261277
switch currentPhase {
262278
case .inhale:
263-
return MetalBreathingState(phase: .inhale, progress: Float(easedT))
279+
return MetalBreathingState(phase: .inhale, progress: Float(easedT), holdTime: 0)
264280
case .holdAfterInhale:
265-
return MetalBreathingState(phase: .holdAfterInhale, progress: 1)
281+
return MetalBreathingState(phase: .holdAfterInhale, progress: 1, holdTime: holdTime)
266282
case .exhale:
267-
return MetalBreathingState(phase: .exhale, progress: Float(1 - easedT))
283+
return MetalBreathingState(phase: .exhale, progress: Float(1 - easedT), holdTime: 0)
268284
case .holdAfterExhale:
269-
return MetalBreathingState(phase: .holdAfterExhale, progress: 0)
285+
return MetalBreathingState(phase: .holdAfterExhale, progress: 0, holdTime: holdTime)
270286
}
271287
}
272288

swift/exhale/MetalOverlayRenderer.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ final class MetalOverlayRenderer: NSObject, MTKViewDelegate {
141141

142142
uniforms.phase = breathingState.phase.metalValue
143143
uniforms.progress = breathingState.progress
144+
uniforms.holdTime = breathingState.holdTime
145+
uniforms.rippleEnabled = settingsModel.holdRippleEnabled ? 1 : 0
144146

145147
uniforms.rectangleScale = settingsModel.colorFillGradient == .on ? 2.0 : 1.0
146148
uniforms.circleGradientScale = settingsModel.colorFillGradient == .on ? 2.0 : 1.0

0 commit comments

Comments
 (0)