Animate page indicator button with cascading pull-down hint

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 <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-21 14:01:16 +03:00
parent 3674e5fb06
commit a877f34e7d

View File

@@ -941,26 +941,52 @@ struct BackupView: View {
} }
} }
// Square advance button with amber border // Square advance button animated to hint pull-down
Button { Button {
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
ringPage = (ringPage + 1) % ringPageCount ringPage = (ringPage + 1) % ringPageCount
} }
} label: { } label: {
Image(systemName: "chevron.down") TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in
.font(.system(size: 9, weight: .medium)) let t = tl.date.timeIntervalSince1970
.foregroundStyle(AppTheme.inkSecondary) // 2.4s cycle: two cascading pulses then a long pause
.frame(width: 30, height: 30) let cycle = t.truncatingRemainder(dividingBy: 2.4)
.overlay( let p1 = indicatorChevronPhase(cycle, start: 0.00, duration: 0.38)
RoundedRectangle(cornerRadius: 7, style: .continuous) let p2 = indicatorChevronPhase(cycle, start: 0.42, duration: 0.38)
.stroke(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.55), lineWidth: 1)
) 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) .buttonStyle(.plain)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
// sin-bell phase: rises from 010 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 // MARK: Storage helpers
private func storageStat(_ value: String, label: String, color: Color) -> some View { private func storageStat(_ value: String, label: String, color: Color) -> some View {