πŸ’‘ TipKit

μ•± κΈ°λŠ₯을 λ°œκ²¬ν•˜κ²Œ λ•λŠ” 인앱 팁

iOS 17+2023 μ‹ κ·œ

✨ TipKitμ΄λž€?

TipKit은 iOS 17μ—μ„œ λ„μž…λœ ν”„λ ˆμž„μ›Œν¬λ‘œ, μ‚¬μš©μžκ°€ μ•±μ˜ μƒˆλ‘œμš΄ κΈ°λŠ₯μ΄λ‚˜ μˆ¨κ²¨μ§„ κΈ°λŠ₯을 μžμ—°μŠ€λŸ½κ²Œ λ°œκ²¬ν•  수 μžˆλ„λ‘ 인앱 νŒμ„ ν‘œμ‹œν•©λ‹ˆλ‹€. Apple의 λ„€μ΄ν‹°λΈŒ μŠ€νƒ€μΌμ„ λ”°λ₯΄λ©°, μ‚¬μš©μž 행동에 따라 μ§€λŠ₯적으둜 νŒμ„ ν‘œμ‹œν•˜κ±°λ‚˜ μˆ¨κΉλ‹ˆλ‹€.

πŸ’‘ 핡심 κΈ°λŠ₯: 인라인/νŒμ˜€λ²„ 팁 Β· κ·œμΉ™ 기반 ν‘œμ‹œ Β· 이벀트 좔적 Β· μžλ™ λ¬΄μ‹œ Β· iCloud 동기화 Β· μ»€μŠ€ν…€ μŠ€νƒ€μΌλ§ Β· λ‹€κ΅­μ–΄ 지원

🎯 1. κΈ°λ³Έ μ„€μ •

μ•± μ‹œμž‘ μ‹œ TipKit을 μ΄ˆκΈ°ν™”ν•©λ‹ˆλ‹€.

App.swift β€” TipKit μ΄ˆκΈ°ν™”
import SwiftUI
import TipKit

@main
struct MyApp: App {
    init() {
        // TipKit μ΄ˆκΈ°ν™”
        try? Tips.configure([
            // 개발 쀑: μ•± μ‹€ν–‰ μ‹œλ§ˆλ‹€ 팁 ν‘œμ‹œ
            .displayFrequency(.immediate),

            // ν”„λ‘œλ•μ…˜: κΈ°λ³Έ μ •μ±… μ‚¬μš©
            // .displayFrequency(.daily)

            // 데이터 μ €μž₯ μœ„μΉ˜
            .datastoreLocation(.applicationDefault)
        ])
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

πŸ’¬ 2. κΈ°λ³Έ 팁 생성

Tip ν”„λ‘œν† μ½œμ„ μ±„νƒν•˜μ—¬ νŒμ„ μ •μ˜ν•©λ‹ˆλ‹€.

FavoriteTip.swift β€” κ°„λ‹¨ν•œ 팁
import TipKit

struct FavoriteTip: Tip {
    // 팁 제λͺ©
    var title: Text {
        Text("즐겨찾기에 μΆ”κ°€ν•˜μ„Έμš”")
    }

    // 팁 μ„€λͺ…
    var message: Text? {
        Text("별 μ•„μ΄μ½˜μ„ νƒ­ν•˜λ©΄ 자주 μ‚¬μš©ν•˜λŠ” ν•­λͺ©μ„ λΉ λ₯΄κ²Œ 찾을 수 μžˆμ–΄μš”")
    }

    // 팁 μ•„μ΄μ½˜
    var image: Image? {
        Image(systemName: "star")
    }
}

// SwiftUI Viewμ—μ„œ μ‚¬μš©
struct ContentView: View {
    let favoriteTip = FavoriteTip()

    var body: some View {
        VStack {
            // 인라인 팁 (뷰에 직접 μ‚½μž…)
            TipView(favoriteTip)

            Button("즐겨찾기 μΆ”κ°€") {
                // μ•‘μ…˜
            }
        }
        .padding()
    }
}

🎨 3. 팁 μŠ€νƒ€μΌ

인라인 팁과 νŒμ˜€λ²„ 팁 두 κ°€μ§€ μŠ€νƒ€μΌμ„ μ œκ³΅ν•©λ‹ˆλ‹€.

TipStyles.swift β€” 팁 μŠ€νƒ€μΌ
import SwiftUI
import TipKit

struct TipStylesView: View {
    let inlineTip = FavoriteTip()
    let popoverTip = SearchTip()

    var body: some View {
        VStack(spacing: 30) {
            // 1. 인라인 팁 (λ·° 계측에 직접 μ‚½μž…)
            TipView(inlineTip, arrowEdge: .top)

            Divider()

            // 2. νŒμ˜€λ²„ 팁 (νŠΉμ • μš”μ†Œμ— ν™”μ‚΄ν‘œλ‘œ μ—°κ²°)
            Button("검색") {
                // 검색 μ•‘μ…˜
            }
            .popoverTip(popoverTip)

            // 3. μ»€μŠ€ν…€ μŠ€νƒ€μΌ 인라인 팁
            TipView(inlineTip) { action in
                // μ•‘μ…˜ λ²„νŠΌ μ»€μŠ€ν„°λ§ˆμ΄μ¦ˆ
                if let action {
                    Button(action: action.handler) {
                        Text(action.label)
                            .foregroundStyle(.white)
                            .padding(8)
                            .background(Color.blue)
                            .cornerRadius(8)
                    }
                }
            }
        }
        .padding()
    }
}

// 검색 팁 μ •μ˜
struct SearchTip: Tip {
    var title: Text {
        Text("λΉ λ₯Έ 검색")
    }

    var message: Text? {
        Text("⌘ + F둜 λΉ λ₯΄κ²Œ 검색할 수 μžˆμ–΄μš”")
    }

    var image: Image? {
        Image(systemName: "magnifyingglass")
    }
}

🎯 4. κ·œμΉ™ 기반 팁 (Rules)

νŠΉμ • 쑰건이 만쑱될 λ•Œλ§Œ νŒμ„ ν‘œμ‹œν•©λ‹ˆλ‹€.

RuleBasedTip.swift β€” κ·œμΉ™ 기반 팁
import TipKit

struct EditTip: Tip {
    // μ‚¬μš©μžκ°€ 3개 ν•­λͺ©μ„ μƒμ„±ν–ˆλŠ”μ§€ 좔적
    @Parameter
    static var itemsCreated: Int = 0

    // νŽΈμ§‘ κΈ°λŠ₯을 μ‚¬μš©ν–ˆλŠ”μ§€ 좔적
    static let hasEditedItem = Event(id: "hasEditedItem")

    var title: Text {
        Text("ν•­λͺ© νŽΈμ§‘ν•˜κΈ°")
    }

    var message: Text? {
        Text("ν•­λͺ©μ„ 길게 λˆŒλŸ¬μ„œ νŽΈμ§‘ν•  수 μžˆμ–΄μš”")
    }

    var image: Image? {
        Image(systemName: "pencil")
    }

    // 팁 ν‘œμ‹œ κ·œμΉ™
    var rules: [Rule] {
        [
            // κ·œμΉ™ 1: 3개 이상 ν•­λͺ©μ„ μƒμ„±ν–ˆμ„ λ•Œ
            #Rule(Self.$itemsCreated) { $0 >= 3 },

            // κ·œμΉ™ 2: 아직 νŽΈμ§‘μ„ ν•œ λ²ˆλ„ μ•ˆ ν–ˆμ„ λ•Œ
            #Rule(Self.hasEditedItem) { $0.donations.count == 0 }
        ]
    }

    // μ˜΅μ…˜: ν‘œμ‹œ ν›„ μ•‘μ…˜
    var actions: [Action] {
        [
            Action(id: "try-edit", title: "μ§€κΈˆ 해보기")
        ]
    }
}

// μ‚¬μš© 예제
struct ItemListView: View {
    let editTip = EditTip()
    @State private var items: [String] = []

    var body: some View {
        VStack {
            TipView(editTip) { action in
                if action.id == "try-edit" {
                    Button("μ§€κΈˆ 해보기") {
                        // νŽΈμ§‘ λͺ¨λ“œλ‘œ μ „ν™˜
                    }
                }
            }

            List(items, id: \.self) { item in
                Text(item)
                    .onLongPressGesture {
                        // νŽΈμ§‘ μ•‘μ…˜
                        Task {
                            await EditTip.hasEditedItem.donate()
                        }
                    }
            }

            Button("ν•­λͺ© μΆ”κ°€") {
                items.append("μƒˆ ν•­λͺ©")

                // νŒŒλΌλ―Έν„° μ—…λ°μ΄νŠΈ
                EditTip.itemsCreated += 1
            }
        }
    }
}

πŸ“Š 5. 이벀트 좔적

μ‚¬μš©μž 행동을 μΆ”μ ν•˜μ—¬ μ μ ˆν•œ μ‹œμ μ— νŒμ„ ν‘œμ‹œν•©λ‹ˆλ‹€.

EventTracking.swift β€” 이벀트 좔적
import TipKit

struct ShareTip: Tip {
    // μ—¬λŸ¬ 이벀트 μ •μ˜
    static let didViewItem = Event(id: "didViewItem")
    static let didCreateItem = Event(id: "didCreateItem")
    static let didShareItem = Event(id: "didShareItem")

    var title: Text {
        Text("μΉœκ΅¬μ—κ²Œ κ³΅μœ ν•˜κΈ°")
    }

    var message: Text? {
        Text("곡유 λ²„νŠΌμœΌλ‘œ μΉœκ΅¬λ“€κ³Ό ν•­λͺ©μ„ κ³΅μœ ν•΄λ³΄μ„Έμš”")
    }

    var rules: [Rule] {
        [
            // 5번 이상 ν•­λͺ©μ„ 봀을 λ•Œ
            #Rule(Self.didViewItem) {
                $0.donations.count >= 5
            },

            // 2개 이상 ν•­λͺ©μ„ λ§Œλ“€μ—ˆμ„ λ•Œ
            #Rule(Self.didCreateItem) {
                $0.donations.count >= 2
            },

            // 아직 곡유λ₯Ό μ•ˆ ν–ˆμ„ λ•Œ
            #Rule(Self.didShareItem) {
                $0.donations.count == 0
            }
        ]
    }
}

// 이벀트 기둝 예제
struct DetailView: View {
    let shareTip = ShareTip()

    var body: some View {
        VStack {
            Text("상세 λ‚΄μš©")

            Button("곡유") {
                // 곡유 μ•‘μ…˜
                Task {
                    await ShareTip.didShareItem.donate()
                }
            }
            .popoverTip(shareTip)
        }
        .task {
            // λ·° μ§„μž… μ‹œ 이벀트 기둝
            await ShareTip.didViewItem.donate()
        }
    }
}

βš™οΈ 6. 팁 관리

팁의 μƒνƒœλ₯Ό ν”„λ‘œκ·Έλž˜λ° λ°©μ‹μœΌλ‘œ μ œμ–΄ν•©λ‹ˆλ‹€.

TipManagement.swift β€” 팁 관리
import TipKit

struct TipManagementView: View {
    let tip = FavoriteTip()

    var body: some View {
        VStack(spacing: 20) {
            TipView(tip)

            // 팁 λ¬΄νš¨ν™” (λ‹€μ‹œλŠ” ν‘œμ‹œ μ•ˆ 함)
            Button("이 팁 λ¬΄νš¨ν™”") {
                tip.invalidate(reason: .actionPerformed)
            }

            // λͺ¨λ“  팁 리셋 (개발/ν…ŒμŠ€νŠΈμš©)
            Button("λͺ¨λ“  팁 리셋") {
                Task {
                    try? await Tips.resetDatastore()
                }
            }

            // λͺ¨λ“  μ΄λ²€νŠΈμ™€ νŒŒλΌλ―Έν„° μ΄ˆκΈ°ν™”
            Button("데이터 μ΄ˆκΈ°ν™”") {
                Task {
                    try? await Tips.configureTips {
                        Tips.showAllTipsForTesting()
                    }
                }
            }
        }
        .padding()
    }
}

// 팁 λ¬΄νš¨ν™” 이유
extension Tip.InvalidationReason {
    // .actionPerformed - μ‚¬μš©μžκ°€ 팁의 μ•‘μ…˜μ„ μˆ˜ν–‰ν•¨
    // .tipClosed - μ‚¬μš©μžκ°€ νŒμ„ 직접 λ‹«μŒ
    // .displayCountExceeded - μ΅œλŒ€ ν‘œμ‹œ 횟수 초과
}

πŸ”„ 7. κ³ κΈ‰ κ·œμΉ™

λ³΅μž‘ν•œ 쑰건을 μ‘°ν•©ν•˜μ—¬ μ§€λŠ₯적으둜 νŒμ„ ν‘œμ‹œν•©λ‹ˆλ‹€.

AdvancedRules.swift β€” κ³ κΈ‰ κ·œμΉ™
import TipKit

struct ProTip: Tip {
    // νŒŒλΌλ―Έν„°λ“€
    @Parameter
    static var userLevel: Int = 0

    @Parameter
    static var isPremiumUser: Bool = false

    // 이벀트
    static let usedBasicFeature = Event(id: "usedBasicFeature")
    static let usedProFeature = Event(id: "usedProFeature")

    var title: Text {
        Text("ν”„λ‘œ κΈ°λŠ₯ μ‚¬μš©ν•˜κΈ°")
    }

    var message: Text? {
        Text("프리미엄 κΈ°λŠ₯으둜 더 λ§Žμ€ μž‘μ—…μ„ ν•  수 μžˆμ–΄μš”")
    }

    var rules: [Rule] {
        [
            // 레벨 5 이상
            #Rule(Self.$userLevel) { $0 >= 5 },

            // 프리미엄 μœ μ €κ°€ μ•„λ‹˜
            #Rule(Self.$isPremiumUser) { $0 == false },

            // κΈ°λ³Έ κΈ°λŠ₯을 10번 이상 μ‚¬μš©
            #Rule(Self.usedBasicFeature) {
                $0.donations.count >= 10
            },

            // ν”„λ‘œ κΈ°λŠ₯은 아직 μ•ˆ 써봄
            #Rule(Self.usedProFeature) {
                $0.donations.count == 0
            },

            // 졜근 7일 이내에 κΈ°λ³Έ κΈ°λŠ₯ μ‚¬μš©
            #Rule(Self.usedBasicFeature) {
                $0.donations.last7Days.count > 0
            }
        ]
    }

    var options: [Option] {
        [
            // μ΅œλŒ€ 3λ²ˆκΉŒμ§€λ§Œ ν‘œμ‹œ
            .maxDisplayCount(3),

            // λ¬΄μ‹œν•˜λ©΄ 더 이상 ν‘œμ‹œ μ•ˆ 함
            .ignoresDisplayFrequency(false)
        ]
    }
}

πŸ“± μ’…ν•© 예제

TipKitDemoView.swift β€” μ’…ν•© 데λͺ¨
import SwiftUI
import TipKit

struct TipKitDemoView: View {
    @State private var items: [String] = []
    @State private var favorites: Set<String> = []

    let favoriteTip = FavoriteTip()
    let editTip = EditTip()

    var body: some View {
        NavigationStack {
            VStack {
                // 인라인 팁
                TipView(favoriteTip)
                    .padding()

                List {
                    ForEach(items, id: \.self) { item in
                        HStack {
                            Text(item)

                            Spacer()

                            Button {
                                toggleFavorite(item)
                            } label: {
                                Image(systemName: favorites.contains(item) ? "star.fill" : "star")
                            }
                        }
                        .contentShape(Rectangle())
                        .onLongPressGesture {
                            Task {
                                await EditTip.hasEditedItem.donate()
                            }
                        }
                    }
                }

                // νŒμ˜€λ²„ 팁
                Button("ν•­λͺ© μΆ”κ°€") {
                    items.append("ν•­λͺ© \(items.count + 1)")
                    EditTip.itemsCreated += 1
                }
                .buttonStyle(.borderedProminent)
                .padding()
                .popoverTip(editTip)
            }
            .navigationTitle("TipKit 데λͺ¨")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("리셋") {
                        Task {
                            try? await Tips.resetDatastore()
                        }
                    }
                }
            }
        }
    }

    func toggleFavorite(_ item: String) {
        if favorites.contains(item) {
            favorites.remove(item)
        } else {
            favorites.insert(item)
            // 즐겨찾기 μΆ”κ°€ μ‹œ 팁 λ¬΄νš¨ν™”
            favoriteTip.invalidate(reason: .actionPerformed)
        }
    }
}

πŸ’‘ HIG κ°€μ΄λ“œλΌμΈ

🎯 μ‹€μ „ ν™œμš©

πŸ“š 더 μ•Œμ•„λ³΄κΈ°

⚑️ 팁: 개발 μ€‘μ—λŠ” .displayFrequency(.immediate)λ₯Ό μ‚¬μš©ν•˜μ—¬ μ•± μ‹€ν–‰ μ‹œλ§ˆλ‹€ νŒμ„ ν…ŒμŠ€νŠΈν•  수 μžˆμŠ΅λ‹ˆλ‹€. ν”„λ‘œλ•μ…˜μ—μ„œλŠ” κΈ°λ³Έκ°’ μ‚¬μš©μ„ ꢌμž₯ν•©λ‹ˆλ‹€.