324 lines
10 KiB
Swift
324 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
/// SVG viewBox dimensions for coordinate conversion
|
|
private let svgSize: CGFloat = 512
|
|
|
|
/// Optical center adjustment (checkmark visual weight is left-heavy)
|
|
private let opticalCenterOffset: CGFloat = 8
|
|
|
|
/// Animatable house roof path (right side)
|
|
struct HouseRightPath: Shape {
|
|
var trimEnd: CGFloat = 1.0
|
|
|
|
var animatableData: CGFloat {
|
|
get { trimEnd }
|
|
set { trimEnd = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let scale = min(rect.width, rect.height) / svgSize
|
|
let ox = opticalCenterOffset * scale // optical offset
|
|
|
|
var path = Path()
|
|
// M256 108 L375 200 Q392 212 392 235 L392 345 Q392 375 362 375 L335 375
|
|
path.move(to: CGPoint(x: 256 * scale + ox, y: 108 * scale))
|
|
path.addLine(to: CGPoint(x: 375 * scale + ox, y: 200 * scale))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: 392 * scale + ox, y: 235 * scale),
|
|
control: CGPoint(x: 392 * scale + ox, y: 212 * scale)
|
|
)
|
|
path.addLine(to: CGPoint(x: 392 * scale + ox, y: 345 * scale))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: 362 * scale + ox, y: 375 * scale),
|
|
control: CGPoint(x: 392 * scale + ox, y: 375 * scale)
|
|
)
|
|
path.addLine(to: CGPoint(x: 335 * scale + ox, y: 375 * scale))
|
|
|
|
return path.trimmedPath(from: 0, to: trimEnd)
|
|
}
|
|
}
|
|
|
|
/// Animatable house roof path (left side)
|
|
struct HouseLeftPath: Shape {
|
|
var trimEnd: CGFloat = 1.0
|
|
|
|
var animatableData: CGFloat {
|
|
get { trimEnd }
|
|
set { trimEnd = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let scale = min(rect.width, rect.height) / svgSize
|
|
let ox = opticalCenterOffset * scale // optical offset
|
|
|
|
var path = Path()
|
|
// M256 108 L137 200 Q120 212 120 235 L120 345 Q120 375 150 375 L177 375
|
|
path.move(to: CGPoint(x: 256 * scale + ox, y: 108 * scale))
|
|
path.addLine(to: CGPoint(x: 137 * scale + ox, y: 200 * scale))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: 120 * scale + ox, y: 235 * scale),
|
|
control: CGPoint(x: 120 * scale + ox, y: 212 * scale)
|
|
)
|
|
path.addLine(to: CGPoint(x: 120 * scale + ox, y: 345 * scale))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: 150 * scale + ox, y: 375 * scale),
|
|
control: CGPoint(x: 120 * scale + ox, y: 375 * scale)
|
|
)
|
|
path.addLine(to: CGPoint(x: 177 * scale + ox, y: 375 * scale))
|
|
|
|
return path.trimmedPath(from: 0, to: trimEnd)
|
|
}
|
|
}
|
|
|
|
/// Animatable checkmark path
|
|
struct CheckmarkPath: Shape {
|
|
var trimEnd: CGFloat = 1.0
|
|
|
|
var animatableData: CGFloat {
|
|
get { trimEnd }
|
|
set { trimEnd = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let scale = min(rect.width, rect.height) / svgSize
|
|
let ox = opticalCenterOffset * scale // optical offset
|
|
|
|
var path = Path()
|
|
// M175 320 L235 380 L355 250
|
|
path.move(to: CGPoint(x: 175 * scale + ox, y: 320 * scale))
|
|
path.addLine(to: CGPoint(x: 235 * scale + ox, y: 380 * scale))
|
|
path.addLine(to: CGPoint(x: 355 * scale + ox, y: 250 * scale))
|
|
|
|
return path.trimmedPath(from: 0, to: trimEnd)
|
|
}
|
|
}
|
|
|
|
/// Animatable window (rounded rectangle)
|
|
struct WindowShape: Shape {
|
|
var scale: CGFloat = 1.0
|
|
|
|
var animatableData: CGFloat {
|
|
get { scale }
|
|
set { scale = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let viewScale = min(rect.width, rect.height) / svgSize
|
|
let ox = opticalCenterOffset * viewScale // optical offset
|
|
|
|
// Window at (230, 175) with size 52x52 and corner radius 12
|
|
let windowX: CGFloat = 230
|
|
let windowY: CGFloat = 175
|
|
let windowSize: CGFloat = 52
|
|
let cornerRadius: CGFloat = 12
|
|
|
|
// Center point of window (with optical offset)
|
|
let centerX = (windowX + windowSize / 2) * viewScale + ox
|
|
let centerY = (windowY + windowSize / 2) * viewScale
|
|
|
|
// Scaled dimensions
|
|
let scaledSize = windowSize * viewScale * scale
|
|
let scaledRadius = cornerRadius * viewScale * scale
|
|
|
|
let windowRect = CGRect(
|
|
x: centerX - scaledSize / 2,
|
|
y: centerY - scaledSize / 2,
|
|
width: scaledSize,
|
|
height: scaledSize
|
|
)
|
|
|
|
return Path(roundedRect: windowRect, cornerRadius: scaledRadius)
|
|
}
|
|
}
|
|
|
|
/// Background rounded rectangle shape
|
|
struct BackgroundShape: Shape {
|
|
func path(in rect: CGRect) -> Path {
|
|
let scale = min(rect.width, rect.height) / svgSize
|
|
|
|
// rect at (24, 24) with 464x464 size and rx/ry=100
|
|
let bgRect = CGRect(
|
|
x: 24 * scale,
|
|
y: 24 * scale,
|
|
width: 464 * scale,
|
|
height: 464 * scale
|
|
)
|
|
|
|
return Path(roundedRect: bgRect, cornerRadius: 100 * scale)
|
|
}
|
|
}
|
|
|
|
/// MyCrib icon view with all paths for animation
|
|
struct MyCribIconView: View {
|
|
// Animation progress values (0 to 1)
|
|
var houseLeftProgress: CGFloat = 1.0
|
|
var houseRightProgress: CGFloat = 1.0
|
|
var windowScale: CGFloat = 1.0
|
|
var checkmarkProgress: CGFloat = 1.0
|
|
var showBackground: Bool = true
|
|
var backgroundOpacity: Double = 1.0
|
|
|
|
// Colors
|
|
var backgroundColor: Color = Color(red: 0.96, green: 0.55, blue: 0.24) // #F58D3D
|
|
var foregroundColor: Color = Color(red: 1.0, green: 0.96, blue: 0.92) // #FFF5EB
|
|
|
|
// Stroke widths (scaled from SVG)
|
|
private let houseStrokeWidth: CGFloat = 28
|
|
private let checkmarkStrokeWidth: CGFloat = 32
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let size = min(geometry.size.width, geometry.size.height)
|
|
let scale = size / svgSize
|
|
// Center offset to position content in middle of available space
|
|
let offsetX = (geometry.size.width - size) / 2
|
|
let offsetY = (geometry.size.height - size) / 2
|
|
|
|
ZStack {
|
|
// Background
|
|
if showBackground {
|
|
BackgroundShape()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 1.0, green: 0.64, blue: 0.28), // #FFA347
|
|
Color(red: 0.96, green: 0.51, blue: 0.20) // #F58233
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.opacity(backgroundOpacity)
|
|
.shadow(color: .black.opacity(0.15), radius: 12 * scale, x: 0, y: 8 * scale)
|
|
}
|
|
|
|
// House left side
|
|
HouseLeftPath(trimEnd: houseLeftProgress)
|
|
.stroke(
|
|
foregroundColor,
|
|
style: StrokeStyle(
|
|
lineWidth: houseStrokeWidth * scale,
|
|
lineCap: .round,
|
|
lineJoin: .round
|
|
)
|
|
)
|
|
|
|
// House right side
|
|
HouseRightPath(trimEnd: houseRightProgress)
|
|
.stroke(
|
|
foregroundColor,
|
|
style: StrokeStyle(
|
|
lineWidth: houseStrokeWidth * scale,
|
|
lineCap: .round,
|
|
lineJoin: .round
|
|
)
|
|
)
|
|
|
|
// Window
|
|
WindowShape(scale: windowScale)
|
|
.fill(foregroundColor)
|
|
|
|
// Checkmark
|
|
CheckmarkPath(trimEnd: checkmarkProgress)
|
|
.stroke(
|
|
foregroundColor,
|
|
style: StrokeStyle(
|
|
lineWidth: checkmarkStrokeWidth * scale,
|
|
lineCap: .round,
|
|
lineJoin: .round
|
|
)
|
|
)
|
|
}
|
|
.frame(width: size, height: size)
|
|
.offset(x: offsetX, y: offsetY)
|
|
}
|
|
.aspectRatio(1, contentMode: .fit)
|
|
}
|
|
}
|
|
|
|
/// Animated version with built-in draw animation
|
|
struct AnimatedMyCribIconView: View {
|
|
@State private var houseLeftProgress: CGFloat = 0
|
|
@State private var houseRightProgress: CGFloat = 0
|
|
@State private var windowScale: CGFloat = 0
|
|
@State private var checkmarkProgress: CGFloat = 0
|
|
@State private var backgroundOpacity: Double = 0
|
|
|
|
var animationDuration: Double = 0.5
|
|
var staggerDelay: Double = 0.15
|
|
var showBackground: Bool = true
|
|
|
|
var body: some View {
|
|
MyCribIconView(
|
|
houseLeftProgress: houseLeftProgress,
|
|
houseRightProgress: houseRightProgress,
|
|
windowScale: windowScale,
|
|
checkmarkProgress: checkmarkProgress,
|
|
showBackground: showBackground,
|
|
backgroundOpacity: backgroundOpacity
|
|
)
|
|
.onAppear {
|
|
animateIn()
|
|
}
|
|
}
|
|
|
|
func animateIn() {
|
|
// Background fade in
|
|
withAnimation(.easeOut(duration: animationDuration * 0.5)) {
|
|
backgroundOpacity = 1.0
|
|
}
|
|
|
|
// House sides draw together
|
|
withAnimation(.easeOut(duration: animationDuration).delay(staggerDelay)) {
|
|
houseLeftProgress = 1.0
|
|
houseRightProgress = 1.0
|
|
}
|
|
|
|
// Window pops in
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(staggerDelay * 2)) {
|
|
windowScale = 1.0
|
|
}
|
|
|
|
// Checkmark draws
|
|
withAnimation(.easeOut(duration: animationDuration * 0.8).delay(staggerDelay * 3)) {
|
|
checkmarkProgress = 1.0
|
|
}
|
|
}
|
|
|
|
func reset() {
|
|
houseLeftProgress = 0
|
|
houseRightProgress = 0
|
|
windowScale = 0
|
|
checkmarkProgress = 0
|
|
backgroundOpacity = 0
|
|
}
|
|
}
|
|
|
|
#Preview("Static Icon") {
|
|
MyCribIconView()
|
|
.frame(width: 200, height: 200)
|
|
.padding()
|
|
}
|
|
|
|
#Preview("Animated Icon") {
|
|
AnimatedMyCribIconView()
|
|
.frame(width: 200, height: 200)
|
|
.padding()
|
|
}
|
|
|
|
#Preview("Icon Without Background") {
|
|
MyCribIconView(showBackground: false)
|
|
.frame(width: 200, height: 200)
|
|
.padding()
|
|
.background(Color.orange)
|
|
}
|
|
|
|
#Preview("Custom Colors") {
|
|
MyCribIconView(
|
|
backgroundColor: .blue,
|
|
foregroundColor: .white
|
|
)
|
|
.frame(width: 200, height: 200)
|
|
.padding()
|
|
}
|