From a877f34e7da456f16deb37a817439e5d8a6acebb Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Thu, 21 May 2026 14:01:16 +0300 Subject: [PATCH] Animate page indicator button with cascading pull-down hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimelineView drives a 2.4s cycle: two sin-bell chevron pulses (primary + ghost trailing 7pt below) then a pause. Mirrors the cascadingArrows pattern — suggests pull-down on all three pages. Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Features/Backup/BackupView.swift | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index a3b0fa0..2af1467 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -941,26 +941,52 @@ struct BackupView: View { } } - // Square advance button with amber border + // Square advance button — animated to hint pull-down Button { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { ringPage = (ringPage + 1) % ringPageCount } } label: { - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(AppTheme.inkSecondary) - .frame(width: 30, height: 30) - .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.55), lineWidth: 1) - ) + TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in + let t = tl.date.timeIntervalSince1970 + // 2.4s cycle: two cascading pulses then a long pause + let cycle = t.truncatingRemainder(dividingBy: 2.4) + let p1 = indicatorChevronPhase(cycle, start: 0.00, duration: 0.38) + let p2 = indicatorChevronPhase(cycle, start: 0.42, duration: 0.38) + + ZStack { + // Primary chevron + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + .offset(y: CGFloat(p1) * 4) + .opacity(1.0 - p1 * 0.35) + // Ghost — trails below with delay + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary.opacity(0.45)) + .offset(y: CGFloat(p2) * 4 + 7) + .opacity(p2 * 0.85) + } + } + .frame(width: 30, height: 30) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.55), lineWidth: 1) + ) } .buttonStyle(.plain) } .frame(maxWidth: .infinity) } + // sin-bell phase: rises from 0→1→0 over `duration` seconds starting at `start` + private func indicatorChevronPhase(_ cycle: Double, start: Double, duration: Double) -> Double { + let t = cycle - start + guard t >= 0, t < duration else { return 0 } + return sin(t / duration * .pi) + } + // MARK: — Storage helpers private func storageStat(_ value: String, label: String, color: Color) -> some View {