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 {
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 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
private func storageStat(_ value: String, label: String, color: Color) -> some View {