앱이 너무 정적으로 보인다
struct AnimationDemo: View {
@State private var isExpanded = false
@State private var rotation: Double = 0
@State private var scale: CGFloat = 1.0
var body: some View {
VStack(spacing: 32) {
// withAnimation — 클로저 내 모든 상태 변화를 애니메이션
RoundedRectangle(cornerRadius: 16)
.fill(Color.orange)
.frame(
width: isExpanded ? 280 : 120,
height: isExpanded ? 160 : 60
)
.onTapGesture {
withAnimation(.spring(response: 0.4,
dampingFraction: 0.7)) {
isExpanded.toggle()
}
}
// .animation() 뷰 모디파이어 — 특정 값이 변할 때
Image(systemName: "star.fill")
.font(.system(size: 40))
.foregroundStyle(.orange)
.rotationEffect(.degrees(rotation))
.scaleEffect(scale)
.animation(.easeInOut(duration: 0.3), value: rotation)
.animation(.spring(), value: scale)
.onTapGesture {
rotation += 45
scale = scale == 1.0 ? 1.5 : 1.0
}
// 다양한 애니메이션 커브
Button("easeIn") {
withAnimation(.easeIn(duration: 0.5)) { isExpanded.toggle() }
}
Button("spring") {
withAnimation(.spring(
response: 0.5,
dampingFraction: 0.6,
blendDuration: 0
)) { isExpanded.toggle() }
}
Button("delay + repeat") {
withAnimation(
.easeInOut(duration: 0.3)
.delay(0.1)
.repeatCount(3, autoreverses: true)
) { scale = 1.3 }
}
}
}
}
withAnimation과 .animation(value:)의 적용 범위 차이는?.spring(response:dampingFraction:)에서 dampingFraction이 1.0에 가까울수록 어떻게 달라지는가?withAnimation(completion:) iOS 17+)struct TransitionDemo: View {
@State private var showBanner = false
@State private var showCard = false
@State private var showModal = false
var body: some View {
VStack(spacing: 20) {
// 기본 transition
Button("배너 토글") {
withAnimation(.spring()) {
showBanner.toggle()
}
}
if showBanner {
Text("🎉 할인 이벤트 진행 중!")
.padding()
.background(Color.orange.opacity(0.15))
.cornerRadius(12)
.transition(.slide) // 옆에서 밀려 들어옴
}
// .scale + .opacity 조합
Button("카드 토글") {
withAnimation(.easeOut(duration: 0.3)) {
showCard.toggle()
}
}
if showCard {
RoundedRectangle(cornerRadius: 16)
.fill(Color.orange.opacity(0.2))
.frame(height: 100)
.transition(.scale.combined(with: .opacity))
}
// asymmetric — 등장/퇴장 다르게
Button("모달 토글") {
withAnimation(.spring(response: 0.45)) {
showModal.toggle()
}
}
if showModal {
Text("아래서 올라오고 위로 사라져요")
.padding()
.background(Color.orange)
.foregroundStyle(.white)
.cornerRadius(12)
.transition(.asymmetric(
insertion: .move(edge: .bottom)
.combined(with: .opacity),
removal: .move(edge: .top)
.combined(with: .opacity)
))
}
}
.padding()
}
}
// AnyTransition 확장 — 커스텀 트랜지션
extension AnyTransition {
static var popUp: AnyTransition {
.scale(scale: 0.1, anchor: .bottom)
.combined(with: .opacity)
}
}
.transition()이 작동하려면 반드시 withAnimation이 필요한 이유는?.move(edge: .bottom)과 .slide의 차이는?.scale.combined(with: .opacity)처럼 체이닝struct HeroAnimationView: View {
@Namespace private var heroNS
@State private var selectedCard: Int? = nil
let cards = [1, 2, 3]
var body: some View {
ZStack {
// 목록 — 작은 카드
if selectedCard == nil {
VStack(spacing: 12) {
ForEach(cards, id: \.self) { id in
RoundedRectangle(cornerRadius: 16)
.fill(Color.orange.opacity(0.15))
.frame(height: 80)
.overlay(
Text("카드 \(id)")
.font(.headline)
)
// 이 뷰에 heroNS + "card\(id)" 지정
.matchedGeometryEffect(
id: "card\(id)",
in: heroNS
)
.onTapGesture {
withAnimation(.spring(
response: 0.45,
dampingFraction: 0.8
)) {
selectedCard = id
}
}
}
}
.padding()
}
// 상세 — 펼쳐진 카드
if let id = selectedCard {
RoundedRectangle(cornerRadius: 24)
.fill(Color.orange.opacity(0.25))
.ignoresSafeArea()
.overlay(
VStack {
Text("카드 \(id) 상세")
.font(.largeTitle.bold())
Button("닫기") {
withAnimation(.spring(
response: 0.45,
dampingFraction: 0.8
)) {
selectedCard = nil
}
}
.padding(.top)
}
)
// 같은 id + namespace → SwiftUI가 보간 처리
.matchedGeometryEffect(
id: "card\(id)",
in: heroNS
)
}
}
}
}
// @Namespace: 두 뷰를 연결하는 ID 공간
// matchedGeometryEffect는 같은 namespace의
// 같은 id를 가진 뷰가 교체될 때 위치/크기를 보간
matchedGeometryEffect는 어떻게 동작하는가? (isSource 파라미터)@Namespace를 여러 개 만들 수 있는가? 언제 분리해야 하는가?// 카드 스타일 modifier
struct CardModifier: ViewModifier {
var color: Color = .orange
func body(content: Content) -> some View {
content
.padding(20)
.background(color.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(color.opacity(0.3), lineWidth: 1)
)
.cornerRadius(16)
.shadow(color: color.opacity(0.15),
radius: 8, x: 0, y: 4)
}
}
// Shimmer 로딩 효과 modifier
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
gradient: Gradient(colors: [
.clear,
.white.opacity(0.5),
.clear
]),
startPoint: .init(x: phase - 0.3, y: 0),
endPoint: .init(x: phase + 0.3, y: 0)
)
.blendMode(.screen)
)
.onAppear {
withAnimation(
.linear(duration: 1.5)
.repeatForever(autoreverses: false)
) {
phase = 1.3
}
}
}
}
// Pressable 효과 modifier
struct PressableModifier: ViewModifier {
@State private var isPressed = false
func body(content: Content) -> some View {
content
.scaleEffect(isPressed ? 0.95 : 1.0)
.opacity(isPressed ? 0.8 : 1.0)
.animation(.easeOut(duration: 0.1), value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in isPressed = true }
.onEnded { _ in isPressed = false }
)
}
}
// View Extension으로 편리하게 사용
extension View {
func cardStyle(color: Color = .orange) -> some View {
modifier(CardModifier(color: color))
}
func shimmer() -> some View {
modifier(ShimmerModifier())
}
func pressable() -> some View {
modifier(PressableModifier())
}
}
// 사용
Text("안녕하세요").cardStyle()
Rectangle().shimmer()
Button("탭!") { }.pressable()
@State를 갖는 이유는? 각 뷰 인스턴스마다 독립적으로 동작하는가?body(content:)를 구현. content는 기존 뷰. 여기에 modifier를 추가해 새 뷰를 반환extension View { func myStyle() → some View }로 SwiftUI 내장 modifier처럼 체이닝 가능하게 만들기struct GestureDemo: View {
@State private var offset: CGSize = .zero
@State private var isDragging = false
@State private var tapCount = 0
@State private var isLongPressed = false
var body: some View {
VStack(spacing: 40) {
// TapGesture
Text("탭 횟수: \(tapCount)")
.padding()
.background(Color.orange.opacity(0.15))
.cornerRadius(12)
.onTapGesture(count: 2) { // 더블탭
tapCount += 1
}
// DragGesture — 드래그로 이동
Circle()
.fill(isDragging ? Color.orange : Color.orange.opacity(0.5))
.frame(width: 80, height: 80)
.offset(offset)
.scaleEffect(isDragging ? 1.1 : 1.0)
.animation(.spring(), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { _ in
withAnimation(.spring()) {
offset = .zero // 제자리 복귀
isDragging = false
}
}
)
// LongPressGesture
RoundedRectangle(cornerRadius: 16)
.fill(isLongPressed ? Color.orange : Color.gray.opacity(0.2))
.frame(height: 60)
.overlay(
Text(isLongPressed ? "꾹 누르는 중..." : "꾹 눌러보세요")
)
.gesture(
LongPressGesture(minimumDuration: 0.5)
.onChanged { _ in
withAnimation { isLongPressed = true }
}
.onEnded { _ in
withAnimation { isLongPressed = false }
}
)
// simultaneousGesture — 여러 제스처 동시 인식
Text("동시 탭 + 드래그")
.onTapGesture { print("탭!") }
.simultaneousGesture(
DragGesture().onChanged { _ in print("드래그!") }
)
}
.padding()
}
}
.gesture()와 .onTapGesture()의 차이는? 제스처 우선순위는 어떻게 결정되는가?DragGesture의 value.translation과 value.predictedEndTranslation의 차이는?.highPriorityGesture로 우선순위 제어 가능// Custom Shape — Shape 프로토콜 채택
struct Wave: Shape {
var amplitude: CGFloat = 10
var frequency: CGFloat = 2
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: rect.midY))
let wavelength = rect.width / frequency
for x in stride(from: 0, to: rect.width, by: 1) {
let y = rect.midY + amplitude *
sin(2 * .pi * x / wavelength)
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
// 원형 진행 인디케이터
struct ArcProgressShape: Shape {
var progress: Double // 0.0 ~ 1.0
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2 - 8
path.addArc(
center: center,
radius: radius,
startAngle: .degrees(-90),
endAngle: .degrees(-90 + 360 * progress),
clockwise: false
)
return path
}
}
// 실제 사용
struct ProgressRing: View {
@State private var progress = 0.0
var body: some View {
ZStack {
Circle()
.stroke(Color.orange.opacity(0.2),
lineWidth: 12)
ArcProgressShape(progress: progress)
.stroke(
Color.orange,
style: StrokeStyle(
lineWidth: 12,
lineCap: .round
)
)
.animation(.easeInOut(duration: 1.5),
value: progress)
Text("\(Int(progress * 100))%")
.font(.title2.bold())
}
.frame(width: 140, height: 140)
.onAppear { progress = 0.75 }
}
}
animatableData를 구현하면 Shape의 어떤 값을 애니메이션으로 만들 수 있는가?Path.addArc에서 각도 계산 시 SwiftUI 좌표계의 방향은 수학과 어떻게 다른가?Shape와 Canvas는 어떻게 다른가? 복잡한 그래픽에서 성능 차이가 있는가?path(in rect:)를 구현. rect는 뷰가 차지하는 공간. Path로 원하는 형태를 그려 반환rotation3DEffect와 withAnimation 활용. 앞면과 뒷면이 각각 다른 내용을 보여야 함