🌐 EN

🌤 나만의 날씨 위젯 만들기
챌린지

⭐ 난이도: ⭐⭐⭐ ⏱️ 예상 시간: 2-3h 📂 App Frameworks

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 가이드라인

📎 Apple 공식 자료

📘 공식 문서 💻 샘플 코드 🎬 WWDC 세션