๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐ŸŽ iOS/๐Ÿ“– Docs

[SwiftUI] InteractiveUI (Gesture, Animation, Transition)

by MINT09 2025. 4. 21.

์•ˆ๋…•ํ•˜์‹ญ๋‹ˆ๊นŒ, ๋ฏผํŠธ์ž…๋‹ˆ๋‹ค. ๐Ÿ˜ˆ

์Šคํ„ฐ๋”” ์ค‘ SwiftUI์˜ InteractiveUI๋“ค์— ๋Œ€ํ•ด ์ •๋ฆฌํ•ด ๋ณด์•˜์Šต๋‹ˆ๋‹ค.

1. Gesture

@GestureState

  • ์ œ์Šค์ฒ˜๊ฐ€ ์ง„ํ–‰ ์ค‘์ผ ๋•Œ๋งŒ ์ž„์‹œ์ ์œผ๋กœ ์ƒํƒœ๋ฅผ ์œ ์ง€
  • ์ œ์Šค์ฒ˜๊ฐ€ ๋๋‚˜๋ฉด ์ž๋™์œผ๋กœ ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ ๋˜๋Œ์•„๊ฐ€๋Š” ์ƒํƒœ ๋ž˜ํผ (์ผ์‹œ์ )
  • ์ƒํƒœ์— ๋”ฐ๋ผ ๋ทฐ ์†์„ฑ์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ณ€ํ™”์‹œํ‚ด
  • @State ์ฒ˜๋Ÿผ ์ง€์†์ ์ธ ๋ณ€ํ™”๊ฐ€ ์•„๋‹Œ, ์ž„์‹œ ์ƒํƒœ ์šฉ๋„
  • ์ œ์Šค์ณ ์ถ”์ ํ•˜๋Š” ๋™์•ˆ ์ฝœ๋ฐฑ์„ ํ†ตํ•ด ์ƒํƒœ๋ฅผ ์ง์ ‘ ๊ฐฑ์‹ 
    • updating: ์ œ์Šค์ณ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋Š” ์ˆœ๊ฐ„, ์ผ์‹œ์ ์ธ UI ์ƒํƒœ
    • onChanged: ์ œ์Šค์ณ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋™์•ˆ์˜ UI ์ƒํƒœ, ์ข…๋ฃŒ ํ›„ ์žฌ์„ค์ • X
    • onEnded: ์ข…๋ฃŒ ํ›„ ์ƒํƒœ ์žฌ์„ค์ •

Gesture Composition Type

  • Simultaneous
    • ์—ฌ๋Ÿฌ ์ œ์Šค์ณ๋ฅผ ํ•ฉ์ณ์„œ ํ•œ ๋ฒˆ์— ์ธ์‹
  • Sequenced
    • ์—ฌ๋Ÿฌ ์ œ์Šค์ณ ์ƒํƒœ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ์ ์šฉ ๊ฐ€๋Šฅ
  • Exclusive
    • ์—ฌ๋Ÿฌ ์ œ์Šค์ณ ์ค‘ ์ฒ˜์Œ ์‹œ๋„ํ•œ ํ•˜๋‚˜์˜ ์ œ์Šค์ณ๋งŒ ํ™œ์„ฑํ™”

์˜ˆ์‹œ ์ฝ”๋“œ

import SwiftUI

struct GestureView: View {
    @GestureState private var isPressed = false
    @State private var offset = CGSize.zero

    var body: some View {
        Circle()
            .fill(isPressed ? Color.blue : Color.red)
            .frame(width: 100, height: 100)
            .offset(offset)
            .gesture(
                LongPressGesture(minimumDuration: 0.5)
                    .sequenced(before: DragGesture())
                    .updating($isPressed) { value, state, _ in
                        if case .first(true) = value {
                            state = true
                        }
                    }
                    .onEnded { value in
                        if case .second(true, let drag?) = value {
                            offset = drag.translation
                        }
                    }
            )
    }
}

2. Animation

  • Apple์ด SwiftUI๋ฅผ ๋งŒ๋“ค๊ฒŒ ๋œ ์ค‘์š”ํ•œ ๊ณ„๊ธฐ ์ค‘ ํ•˜๋‚˜
  • State์˜ ๋ณ€ํ™”๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋™์ž‘

๋ช…์‹œ์  vs ์•”๋ฌต์ 

  • ๋ช…์‹œ์ 
    • ๊ฐœ๋ฐœ์ž๊ฐ€ ์ง์ ‘ withAnimation ๋ธ”๋ก ์•ˆ์—์„œ ์ƒํƒœ ๋ณ€๊ฒฝ ํŠธ๋ฆฌ๊ฑฐ
    • ์ œ์–ด ์ˆ˜์ค€ ๋†’์Œ (์ •ํ™•ํ•œ ์œ„์น˜์— ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ ์šฉ)
    • ์„ ์–ธ์ ์ด๋‚˜ ์˜๋„ ๋ช…ํ™•
    • ์‚ฌ์šฉ์ž๊ฐ€ ์ œ์–ดํ•ด์•ผ ํ•  ์ƒํ˜ธ์ž‘์šฉ, ์ „ํ™˜์— ์ ํ•ฉ
  • ์•”์‹œ์ 
    • ๋ทฐ์— .animation modifier๋ฅผ ์ง€์ •ํ•˜๋ฉด ํŠน์ • ์ƒํƒœ๊ฐ’์˜ ๋ณ€ํ™”์— ๋”ฐ๋ผ ์ž๋™์œผ๋กœ ์ ์šฉ
    • ์ œ์–ด ์ˆ˜์ค€ ๋‚ฎ์Œ (์ „์ฒด ๋ทฐ์— ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ ์šฉ๋จ)
    • ์„ ์–ธ์ ์œผ๋กœ ๊ฐ„๋‹จํ•˜๋‚˜ ์˜ˆ์ธก์ด ์–ด๋ ค์šธ ์ˆ˜๋„ ์žˆ์Œ.
    • ๋‹จ์ˆœํ•œ UI ์ƒํƒœ ๋ณ€ํ™”

matchedGeometryEffect

  • ๋‘ ๊ฐœ์˜ ๋ทฐ๊ฐ€ ์„œ๋กœ ๋‹ค๋ฅธ ์œ„์น˜๋‚˜ ํฌ๊ธฐ๋ผ๋„ ์‹œ์Šคํ…œ์ด ์ด๋“ค์„ ๋™์ผํ•œ ๋ทฐ๋กœ ์ธ์‹ํ•˜๊ฒŒ ํ•ด์ฃผ๋Š” ๊ธฐ๋Šฅ
  • ๋ทฐ ๊ฐ„ ์ „ํ™˜ ์‹œ ์œ„์น˜, ํฌ๊ธฐ, ๋ชจ์–‘ ๋“ฑ์„ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์• ๋‹ˆ๋ฉ”์ด์…˜์œผ๋กœ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋งŒ๋“ค์–ด์คŒ.
  • ์ž‘์€ ๋ทฐ์™€ ํฐ ๋ทฐ๋Š” ์ „ํ˜€ ๋‹ค๋ฅธ ๋ทฐ์ด์ง€๋งŒ, ํ•˜๋‚˜์˜ ๋ทฐ์ฒ˜๋Ÿผ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ด์–ด์ง„๋‹ค.

@Namespace

@Namespace private var animation
  • matchedGeometryEffect๊ฐ€ ์–ด๋–ค ๋ทฐ๋“ค์ด ๊ฐ™์€ ๋ทฐ์ธ์ง€ ์ธ์‹ํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ณต๊ฐ(namespace)๋ฅผ ๊ณต์œ ํ•˜๋Š” ๋ณ€์ˆ˜
  • ๋ทฐ ๊ฐ„์˜ ์—ฐ๊ฒฐ ๊ณ ๋ฆฌ
  • id: ์—ฐ๊ฒฐ์„ ์œ„ํ•œ ํ‚ค๊ฐ’
  • animation: ๊ฐ™์€ ๊ณต๊ฐ„ ์•ˆ์— ์žˆ๋‹ค๋Š” ํ‘œ์‹œ

ํ”„๋กœ์ ํŠธ ๋ณ„ ์„ ํƒ ๊ธฐ์ค€

  • animation
    • ๋‹จ์ˆœ ์ƒํƒœ ๋ณ€ํ™”
  • withAnimation
    • ์‚ฌ์šฉ์ž ์•ก์…˜์— ๋”ฐ๋ผ ๋ช…ํ™•ํžˆ ์ œ์–ดํ•ด์•ผ ํ•  ๋•Œ
  • matchedGeometryEffect + @Namespace
    • View ๊ฐ„์— ์ž์—ฐ์Šค๋Ÿฌ์šด ์ „ํ™˜์ด ํ•„์š”ํ•  ๋•Œ

๋ช…์‹œ์ , ์•”์‹œ์ 

์˜ˆ์ œ ์ฝ”๋“œ

//๋ช…์‹œ์ 
struct AnimationView: View {
    @Namespace private var animation
    @State private var isExpanded = false

    var body: some View {
        VStack {
            if isExpanded {
                RoundedRectangle(cornerRadius: 25)
                    .fill(Color.green)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(width: 300, height: 300)
                    .onTapGesture {
                        withAnimation(.spring()) {
                            isExpanded.toggle()
                        }
                    }
            } else {
                RoundedRectangle(cornerRadius: 25)
                    .fill(Color.green)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        withAnimation(.spring()) {
                            isExpanded.toggle()
                        }
                    }
            }
        }
    }
}

//์•”๋ฌต์ 
struct ImplicitAnimationView: View {
    @State private var isOn = false

    var body: some View {
        Circle()
            .fill(isOn ? Color.green : Color.red)
            .frame(width: isOn ? 200 : 100, height: isOn ? 200 : 100)
            .onTapGesture {
                isOn.toggle()
            }
            .animation(.easeInOut, value: isOn)
    }
}

3. Transition

  • insertion๊ณผ removal์— ์„œ๋กœ ๋‹ค๋ฅธ ํšจ๊ณผ ์ ์šฉ ๊ฐ€๋Šฅ
  • ๊ฐ๊ฐ ๋ณ„๊ฐœ์˜ identity๋ฅผ ์ ์šฉ ์‹œํ‚ค๊ธฐ์— ๊ฐ€๋Šฅ

AnyTransition.modifier

  • ์ปค์Šคํ…€ ์ „ํ™˜ ํšจ๊ณผ๋ฅผ ๊ตฌํ˜„ํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ๊ณ ๊ธ‰ ๋„๊ตฌ
  • ๊ธฐ๋ณธ ํšจ๊ณผ ์™ธ์— ์ง์ ‘ ๋งŒ๋“  ์‹œ๊ฐ ํšจ๊ณผ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์œ ์šฉ
    • .opacity, .slide, .scale
  • ๋ทฐ๊ฐ€ ์‚ฝ์ž…๋˜๊ฑฐ๋‚˜ ์ œ๊ฑฐ๋  ๋•Œ, ์›ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ทฐ๋ฅผ ์ˆ˜์ •ํ•ด์„œ ์ „ํ™˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” transition ์ƒ์„ฑ์ž
AnyTransition.modifier(
    active: ViewModifier,   // ์ „ํ™˜ ์ค‘์— ์ ์šฉ๋  ์ƒํƒœ
    identity: ViewModifier  // ํ‰์†Œ ์ƒํƒœ
)

 

์˜ˆ์‹œ ์ฝ”๋“œ

//Modifier ์ •์˜
struct BlurModifier: ViewModifier {
    let amount: CGFloat

    func body(content: Content) -> some View {
        content
            .blur(radius: amount)
            .opacity(1 - Double(amount / 10))
    }
}

//Transition ์ •์˜
extension AnyTransition {
    static var blurFade: AnyTransition {
        .modifier(
            active: BlurModifier(amount: 10),
            identity: BlurModifier(amount: 0)
        )
    }
}

struct TransitionView: View {
    @State private var show = false

    var body: some View {
        VStack(spacing: 20) {
            Button("Toggle Box") {
                withAnimation {
                    show.toggle()
                }
            }

            if show {
                RoundedRectangle(cornerRadius: 20)
                    .fill(Color.purple)
                    .frame(width: 200, height: 200)
                    .transition(.blurFade)
            }
        }
    }
}

 

https://github.com/prography/10th-iOS-Study/tree/main/%EC%8B%AC%ED%99%94/3.%20%EA%B3%A0%EA%B8%89%20%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98%20%EC%A0%84%ED%99%98%20%EB%B0%8F%20%EC%A0%9C%EC%8A%A4%EC%B3%90%20%ED%99%9C%EC%9A%A9

 

10th-iOS-Study/์‹ฌํ™”/3. ๊ณ ๊ธ‰ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ „ํ™˜ ๋ฐ ์ œ์Šค์ณ ํ™œ์šฉ at main · prography/10th-iOS-Study

10๊ธฐ iOS ํŒŒํŠธ์›๋“ค์˜ ์Šคํ„ฐ๋”” ๊ณต๊ฐ„์ž…๋‹ˆ๋‹ค. Contribute to prography/10th-iOS-Study development by creating an account on GitHub.

github.com

 

'๐ŸŽ iOS > ๐Ÿ“– Docs' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

[iOS]Unit Test์˜ ์‚ฌ์šฉ  (0) 2024.02.05
[๋””์ž์ธ ํŒจํ„ด]Observer Pattern  (1) 2024.01.27