Files
honeyDueKMP/iosApp/iosApp/Subviews/Common/MyCribIconView.swift
2025-12-04 23:59:39 -06:00

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()
}