๐ŸŒค ๋‚˜๋งŒ์˜ ๋‚ ์”จ ์œ„์ ฏ ๋งŒ๋“ค๊ธฐ
์ฑŒ๋ฆฐ์ง€

Apple HIG์˜ ์œ„์ ฏ ๋””์ž์ธ ๊ฐ€์ด๋“œ๋ผ์ธ์„ ๋”ฐ๋ผ, ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ๋‚ ์”จ ์œ„์ ฏ์„ SwiftUI๋กœ ๋งŒ๋“ค์–ด๋ด…๋‹ˆ๋‹ค.

๐Ÿ“… 2025.02 โฑ ์ด ์‹ค์Šต 2์‹œ๊ฐ„ ๐Ÿ“ฑ iOS 17+ / Xcode 15+ ๐ŸŽฏ ์ดˆ๊ธ‰ โ†’ ์ค‘๊ธ‰
๐Ÿ”„ ์„ฑ์žฅ๊ณ ๋ฆฌ ์ปค๋ฆฌํ˜๋Ÿผ

5๋‹จ๊ณ„ ์„ฑ์žฅ๊ณ ๋ฆฌ๋ฅผ ๋”ฐ๋ผ๊ฐ€๋ฉฐ ์œ„์ ฏ ๊ฐœ๋ฐœ์˜ ์ „ ๊ณผ์ •์„ ๋งˆ์Šคํ„ฐํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“–
Ring 1 ยท ์ดํ•ดํ•˜๊ธฐ
HIG ์œ„์ ฏ ๊ฐ€์ด๋“œ๋ผ์ธ ํ•™์Šต
Glanceable, Relevant, Personalized โ€” ์œ„์ ฏ์˜ 3๋Œ€ ์›์น™
๐Ÿ—๏ธ
Ring 2 ยท ๋ผˆ๋Œ€ ์žก๊ธฐ
Widget Extension ๊ธฐ๋ณธ ๊ตฌ์กฐ
TimelineProvider, Entry, View์˜ ํ•ต์‹ฌ ์•„ํ‚คํ…์ฒ˜
๐ŸŽจ
Ring 3 ยท ๊พธ๋ฏธ๊ธฐ
HIG ์ค€์ˆ˜ UI ๋””์ž์ธ
ํฌ๊ธฐ๋ณ„ ๋ ˆ์ด์•„์›ƒ, ๋‹คํฌ๋ชจ๋“œ, ํ‹ดํŠธ ๋Œ€์‘
โšก
Ring 4 ยท ์—ฐ๊ฒฐํ•˜๊ธฐ
๋ฐ์ดํ„ฐ & ์ธํ„ฐ๋ž™์…˜
๋‚ ์”จ API ์—ฐ๋™, ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ๋ฒ„ํŠผ, ๋”ฅ๋งํฌ
๐Ÿš€
Ring 5 ยท ์™„์„ฑํ•˜๊ธฐ
์„ค์ • & ํผ์Šค๋„๋ผ์ด์ฆˆ
App Intent ์„ค์ •, ์ž ๊ธˆ ํ™”๋ฉด ์œ„์ ฏ

๐Ÿ“– Ring 1 โ€” HIG ์œ„์ ฏ ๊ฐ€์ด๋“œ๋ผ์ธ

Apple์€ ์œ„์ ฏ์„ "๋ฏธ๋‹ˆ ์•ฑ"์ด ์•„๋‹ˆ๋ผ ์•ฑ์˜ ํ•ต์‹ฌ ์ •๋ณด๋ฅผ ํ™ˆ ํ™”๋ฉด์— ํˆฌ์˜ํ•˜๋Š” ๊ฒƒ์ด๋ผ๊ณ  ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ํ•˜๋ฃจ ํ‰๊ท  90๋ฒˆ ์ด์ƒ ํ™ˆ ํ™”๋ฉด์„ ๋ณด์ง€๋งŒ, ๋จธ๋ฌด๋Š” ์‹œ๊ฐ„์€ ๋ช‡ ์ดˆ์— ๋ถˆ๊ณผํ•ฉ๋‹ˆ๋‹ค.

๐ŸŽ HIG ํ•ต์‹ฌ ์›์น™ 3๊ฐ€์ง€

1. Glanceable (ํ•œ๋ˆˆ์— ํŒŒ์•…)
์‚ฌ์šฉ์ž๊ฐ€ ๋ช‡ ์ดˆ ์•ˆ์— ํ•ต์‹ฌ ์ •๋ณด๋ฅผ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

2. Relevant (์‹œ์˜์ ์ ˆ)
์‹œ๊ฐ„, ์žฅ์†Œ, ์‚ฌ์šฉ์ž์˜ ์ƒํ™ฉ์— ๋งž๋Š” ์ •๋ณด๋ฅผ ๋ณด์—ฌ์ฃผ์„ธ์š”.

3. Personalized (๊ฐœ์ธํ™”)
์œ„์ ฏ ์„ค์ •์„ ํ†ตํ•ด ์‚ฌ์šฉ์ž๊ฐ€ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜์„ธ์š”.

โœ… Do

  • ์ฝ˜ํ…์ธ ๊ฐ€ ์ฃผ์ธ๊ณต โ€” ํฐ ์ˆซ์ž, ๋ช…ํ™•ํ•œ ์•„์ด์ฝ˜
  • ํฌ๊ธฐ๋ณ„๋กœ ๋‹ค๋ฅธ ๋ ˆ์ด์•„์›ƒ ์ œ๊ณต
  • ๋‹คํฌ๋ชจ๋“œ/ํ‹ดํŠธ ์ž๋™ ๋Œ€์‘
  • containerBackground๋กœ ๋ฐฐ๊ฒฝ ์ฒ˜๋ฆฌ

โŒ Don't

  • ์ž‘์€ ํฌ๊ธฐ์— ๋„ˆ๋ฌด ๋งŽ์€ ์ •๋ณด
  • ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ
  • ์•ฑ ์•„์ด์ฝ˜/์ด๋ฆ„ ๋ฐ˜๋ณต
  • ์‹ค์‹œ๊ฐ„ ์ดˆ ๋‹จ์œ„ ์—…๋ฐ์ดํŠธ

๐Ÿ—๏ธ Ring 2 โ€” Widget Extension ๊ธฐ๋ณธ ๊ตฌ์กฐ

WidgetKit์€ TimelineProvider โ†’ TimelineEntry โ†’ View์˜ 3๊ณ„์ธต ๊ตฌ์กฐ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

Step 2-1: ๋‚ ์”จ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ

WeatherData.swift Swift
import Foundation

struct WeatherData {
    let cityName: String
    let temperature: Int
    let highTemperature: Int
    let lowTemperature: Int
    let condition: WeatherCondition
    let humidity: Int
    let windSpeed: Double
    let hourlyForecast: [HourlyWeather]
}

enum WeatherCondition: String, CaseIterable {
    case sunny = "๋ง‘์Œ"
    case cloudy = "ํ๋ฆผ"
    case rainy = "๋น„"
    case snowy = "๋ˆˆ"
    case stormy = "๋‡Œ์šฐ"
    
    var symbol: String {
        switch self {
        case .sunny:  "sun.max.fill"
        case .cloudy: "cloud.fill"
        case .rainy:  "cloud.rain.fill"
        case .snowy:  "cloud.snow.fill"
        case .stormy: "cloud.bolt.rain.fill"
        }
    }
}

struct HourlyWeather: Identifiable {
    let id = UUID()
    let hour: String
    let temperature: Int
    let condition: WeatherCondition
}

Step 2-2: AppIntentTimelineProvider ๊ตฌํ˜„ (iOS 17+)

iOS 17๋ถ€ํ„ฐ AppIntentTimelineProvider๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด App Intents์™€ ํ†ตํ•ฉ๋˜์–ด ์‚ฌ์šฉ์ž๊ฐ€ ์œ„์ ฏ์„ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

WeatherProvider.swift Swift
import WidgetKit
import SwiftUI
import AppIntents

struct WeatherEntry: TimelineEntry {
    let date: Date
    let weather: WeatherData
    let configuration: SelectCityIntent
}

// iOS 17+ AppIntentTimelineProvider (async/await ๊ธฐ๋ฐ˜)
struct WeatherProvider: AppIntentTimelineProvider {
    
    // ์œ„์ ฏ ๊ฐค๋Ÿฌ๋ฆฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์šฉ
    func placeholder(in context: Context) -> WeatherEntry {
        WeatherEntry(date: .now, weather: .preview, configuration: SelectCityIntent())
    }
    
    // ์œ„์ ฏ ์ถ”๊ฐ€ ์‹œ ์Šค๋ƒ…์ƒท (async)
    func snapshot(for configuration: SelectCityIntent, in context: Context) async -> WeatherEntry {
        let weather = await WeatherService.shared.fetchWeather(for: configuration.city)
        return WeatherEntry(date: .now, weather: weather, configuration: configuration)
    }
    
    // ์‹ค์ œ ํƒ€์ž„๋ผ์ธ ์ƒ์„ฑ (async)
    func timeline(for configuration: SelectCityIntent, in context: Context) async -> Timeline<WeatherEntry> {
        let weather = await WeatherService.shared.fetchWeather(for: configuration.city)
        let entry = WeatherEntry(date: .now, weather: weather, configuration: configuration)
        
        // 15๋ถ„ ํ›„ ๋‹ค์Œ ๊ฐฑ์‹ 
        let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
        
        return Timeline(entries: [entry], policy: .after(nextUpdate))
    }
}
โœ…

Ring 2 ์ฒดํฌํฌ์ธํŠธ

TimelineProvider๊ฐ€ ๋‚ ์”จ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์™€ Entry๋ฅผ ์ƒ์„ฑํ•˜๊ณ , 15๋ถ„ ๊ฐฑ์‹  ์ •์ฑ…์„ ์„ค์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.

๐ŸŽจ Ring 3 โ€” HIG ์ค€์ˆ˜ UI ๋””์ž์ธ

ํฌ๊ธฐ๋ณ„๋กœ ์ตœ์ ํ™”๋œ ๋ ˆ์ด์•„์›ƒ๊ณผ ๋‚ ์”จ ์กฐ๊ฑด์— ๋”ฐ๋ฅธ ๊ทธ๋ž˜๋””์–ธํŠธ ๋ฐฐ๊ฒฝ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

Step 3-1: Small ์œ„์ ฏ ๋ทฐ

SmallWeatherView.swift Swift
struct SmallWeatherView: View {
    let weather: WeatherData
    
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            // HIG: ์ฝ˜ํ…์ธ ๊ฐ€ ์ฃผ์ธ๊ณต
            Image(systemName: weather.condition.symbol)
                .font(.title2)
                .symbolRenderingMode(.multicolor)
            
            Spacer()
            
            // HIG: ํ•œ๋ˆˆ์— ํŒŒ์•… โ€” ํฐ ๊ธฐ์˜จ ์ˆซ์ž
            Text("\(weather.temperature)ยฐ")
                .font(.system(size: 36, weight: .light))
                .contentTransition(.numericText())
            
            VStack(alignment: .leading, spacing: 0) {
                Text(weather.cityName)
                    .font(.caption)
                    .fontWeight(.semibold)
                
                Text("H:\(weather.highTemperature)ยฐ L:\(weather.lowTemperature)ยฐ")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
    }
}

Step 3-2: Medium ์œ„์ ฏ โ€” ์‹œ๊ฐ„๋ณ„ ์˜ˆ๋ณด

MediumWeatherView.swift Swift
struct MediumWeatherView: View {
    let weather: WeatherData
    
    var body: some View {
        HStack(spacing: 16) {
            // ์™ผ์ชฝ: ํ˜„์žฌ ๋‚ ์”จ
            VStack(alignment: .leading, spacing: 4) {
                Text(weather.cityName)
                    .font(.subheadline)
                    .fontWeight(.semibold)
                
                Text("\(weather.temperature)ยฐ")
                    .font(.system(size: 44, weight: .light))
                
                Text(weather.condition.rawValue)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            
            Divider()
            
            // ์˜ค๋ฅธ์ชฝ: ์‹œ๊ฐ„๋ณ„ ์˜ˆ๋ณด
            HStack(spacing: 12) {
                ForEach(weather.hourlyForecast.prefix(5)) { hourly in
                    VStack(spacing: 6) {
                        Text(hourly.hour)
                            .font(.caption2)
                        Image(systemName: hourly.condition.symbol)
                            .symbolRenderingMode(.multicolor)
                        Text("\(hourly.temperature)ยฐ")
                            .font(.caption)
                    }
                }
            }
        }
    }
}

Step 3-3: ๋‚ ์”จ ๊ธฐ๋ฐ˜ ๊ทธ๋ž˜๋””์–ธํŠธ ๋ฐฐ๊ฒฝ

WeatherGradient.swift Swift
extension WeatherCondition {
    var gradient: LinearGradient {
        switch self {
        case .sunny:
            LinearGradient(
                colors: [.orange, .yellow],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        case .cloudy:
            LinearGradient(
                colors: [Color("CloudTop"), Color("CloudBottom")],
                startPoint: .top,
                endPoint: .bottom
            )
        case .rainy:
            LinearGradient(
                colors: [.gray, .blue.opacity(0.6)],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        case .snowy:
            LinearGradient(
                colors: [.white, Color("IceBlue")],
                startPoint: .top,
                endPoint: .bottom
            )
        case .stormy:
            LinearGradient(
                colors: [Color("StormDark"), Color("StormPurple")],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        }
    }
}
โœ…

Ring 3 ์ฒดํฌํฌ์ธํŠธ

Small/Medium ํฌ๊ธฐ๋ณ„ ์ตœ์  ๋ ˆ์ด์•„์›ƒ๊ณผ ๋‚ ์”จ ์กฐ๊ฑด๋ณ„ ๊ทธ๋ž˜๋””์–ธํŠธ ๋ฐฐ๊ฒฝ์„ ์™„์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

โšก Ring 4 โ€” ๋ฐ์ดํ„ฐ & ์ธํ„ฐ๋ž™์…˜

iOS 17๋ถ€ํ„ฐ ์œ„์ ฏ์— Button๊ณผ Toggle์„ ๋„ฃ์–ด ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒํ•˜๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Step 4-1: ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ

RefreshWeatherIntent.swift Swift
import AppIntents
import WidgetKit

struct RefreshWeatherIntent: AppIntent {
    static var title: LocalizedStringResource = "๋‚ ์”จ ์ƒˆ๋กœ๊ณ ์นจ"
    
    func perform() async throws -> some IntentResult {
        WidgetCenter.shared.reloadAllTimelines()
        return .result()
    }
}

// ์œ„์ ฏ ๋ทฐ์—์„œ ์‚ฌ์šฉ:
Button(intent: RefreshWeatherIntent()) {
    Image(systemName: "arrow.clockwise")
        .font(.caption)
}
.buttonStyle(.plain)
โœ…

Ring 4 ์ฒดํฌํฌ์ธํŠธ

์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ๊นŒ์ง€ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค!

๐Ÿš€ Ring 5 โ€” ์„ค์ • & ํผ์Šค๋„๋ผ์ด์ฆˆ

App Intent Configuration์œผ๋กœ ์‚ฌ์šฉ์ž๊ฐ€ ๋„์‹œ๋ฅผ ์„ ํƒํ•˜๊ฒŒ ํ•˜๊ณ , ์ž ๊ธˆ ํ™”๋ฉด ์œ„์ ฏ๊นŒ์ง€ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

Step 5-1: ๋„์‹œ ์„ ํƒ Configuration

SelectCityIntent.swift Swift
import AppIntents
import WidgetKit

struct SelectCityIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "๋„์‹œ ์„ ํƒ"
    static var description: IntentDescription = "๋‚ ์”จ๋ฅผ ํ™•์ธํ•  ๋„์‹œ๋ฅผ ์„ ํƒํ•˜์„ธ์š”."
    
    @Parameter(title: "๋„์‹œ", default: .seoul)
    var city: CityOption
}

enum CityOption: String, AppEnum {
    case seoul   = "์„œ์šธ"
    case busan   = "๋ถ€์‚ฐ"
    case jeju    = "์ œ์ฃผ"
    case daejeon = "๋Œ€์ „"
    case gwangju = "๊ด‘์ฃผ"
    
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "๋„์‹œ")
    static var caseDisplayRepresentations: [CityOption: DisplayRepresentation] = [
        .seoul:   "์„œ์šธ",
        .busan:   "๋ถ€์‚ฐ",
        .jeju:    "์ œ์ฃผ",
        .daejeon: "๋Œ€์ „",
        .gwangju: "๊ด‘์ฃผ",
    ]
}

Step 5-2: ์ตœ์ข… ์œ„์ ฏ ๊ตฌ์„ฑ

WeatherWidget.swift Swift
import WidgetKit
import SwiftUI

struct WeatherWidget: Widget {
    let kind = "WeatherWidget"
    
    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: kind,
            intent: SelectCityIntent.self,
            provider: WeatherProvider()
        ) { entry in
            WeatherWidgetEntryView(entry: entry)
                .containerBackground(
                    entry.weather.condition.gradient,
                    for: .widget
                )
        }
        .configurationDisplayName("๋‚ ์”จ")
        .description("ํ˜„์žฌ ๋‚ ์”จ์™€ ์˜ˆ๋ณด๋ฅผ ํ™•์ธํ•˜์„ธ์š”.")
        .supportedFamilies([
            .systemSmall, .systemMedium, .systemLarge,
            .accessoryCircular, .accessoryRectangular, .accessoryInline
        ])
    }
}

struct WeatherWidgetEntryView: View {
    @Environment(\.widgetFamily) var family
    let entry: WeatherEntry
    
    var body: some View {
        switch family {
        case .systemSmall:
            SmallWeatherView(weather: entry.weather)
        case .systemMedium:
            MediumWeatherView(weather: entry.weather)
        case .systemLarge:
            LargeWeatherView(weather: entry.weather)
        case .accessoryCircular:
            CircularWeatherView(weather: entry.weather)
        case .accessoryRectangular:
            RectangularWeatherView(weather: entry.weather)
        case .accessoryInline:
            Text("\(entry.weather.condition.rawValue) \(entry.weather.temperature)ยฐ")
        default:
            SmallWeatherView(weather: entry.weather)
        }
    }
}
๐ŸŽ‰

์ฑŒ๋ฆฐ์ง€ ์™„๋ฃŒ!

5๋‹จ๊ณ„ ์„ฑ์žฅ๊ณ ๋ฆฌ๋ฅผ ๋ชจ๋‘ ์™„์ฃผํ–ˆ์Šต๋‹ˆ๋‹ค. HIG๋ฅผ ๋”ฐ๋ฅด๋Š” ์™„์ „ํ•œ ๋‚ ์”จ ์œ„์ ฏ์ด ์™„์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!

๐Ÿ† ๋ณด๋„ˆ์Šค ์ฑŒ๋ฆฐ์ง€

๐Ÿ”ฅ ๋” ๋„์ „ํ•ด๋ณด์„ธ์š”!

๊ธฐ๋ณธ ์ฑŒ๋ฆฐ์ง€๋ฅผ ์™„๋ฃŒํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜ ๋ณด๋„ˆ์Šค ๊ณผ์ œ๋กœ ์‹ค๋ ฅ์„ ํ•œ ๋‹จ๊ณ„ ๋” ๋Œ์–ด์˜ฌ๋ฆฌ์„ธ์š”.

๐Ÿ“ฆ ํ•™์Šต ์ž๋ฃŒ

๐Ÿ“š
DocC ํŠœํ† ๋ฆฌ์–ผ
Xcode์—์„œ ๋ฐ”๋กœ ์‹ค์Šต
๐Ÿ’ป
GitHub ํ”„๋กœ์ ํŠธ
์ „์ฒด ์†Œ์Šค์ฝ”๋“œ
๐ŸŽ
Apple HIG ์›๋ฌธ
Widgets ๊ฐ€์ด๋“œ๋ผ์ธ