2 Commits
2 changed files with 120 additions and 9 deletions
+97 -7
View File
@@ -85,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
@@ -122,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue)
@@ -154,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange)
@@ -186,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal)
@@ -326,6 +323,99 @@ struct OnboardingView: View {
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {
enum Motion {
case gentle
case locationArrow
case alarmBell
case downloadArrow
}
let tint: Color
var motion: Motion = .gentle
@ViewBuilder var content: Content
@State private var settled = false
@State private var iconRotation: Double = 0
@State private var iconOffsetY: CGFloat = 0
@State private var iconScale: CGFloat = 0.92
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
content
.scaleEffect(iconScale)
.rotationEffect(.degrees(iconRotation))
.offset(y: iconOffsetY)
}
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
.onAppear {
playAnimation()
}
}
private func playAnimation() {
settled = false
iconRotation = 0
iconOffsetY = 4
iconScale = 0.92
switch motion {
case .gentle:
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
settled = true
iconOffsetY = 0
iconScale = 1.0
}
case .locationArrow:
iconRotation = -120
withAnimation(.linear(duration: 0.45)) {
settled = true
iconRotation = 24
iconOffsetY = 0
iconScale = 1.02
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
iconRotation = 0
iconScale = 1.0
}
case .alarmBell:
settled = true
iconOffsetY = 0
iconScale = 1.0
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
case .downloadArrow:
iconOffsetY = -22
withAnimation(.easeIn(duration: 0.22)) {
settled = true
iconScale = 1.0
iconOffsetY = 8
}
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
iconOffsetY = 0
}
}
}
}
/// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live.
@MainActor
+23 -2
View File
@@ -342,20 +342,26 @@ struct FuelTypeSegmentedPicker: View {
var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View {
HStack(spacing: 3) {
ForEach(fuels) { fuel in
let isSelected = fuel == selection
Button {
selection = fuel
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel
}
onSelect(fuel)
} label: {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
}
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity)
@@ -364,14 +370,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected {
Capsule()
.fill(Color(UIColor.systemBackground))
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
.overlay {
Capsule()
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
}
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
}
}
.scaleEffect(isSelected ? 1.02 : 0.98)
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground)))
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
}
}
#Preview("Fuel Picker") {
@Previewable @State var selectedFuel: FuelType = .e10
FuelTypeSegmentedPicker(selection: $selectedFuel)
.padding()
.background(Color(.systemGroupedBackground))
}