Compare commits
2
Commits
e00e3ff365
...
2205a585f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2205a585f1 | ||
|
|
f9b70dbb9d |
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user