🌤 나만의 날씨 위젯 만들기
챌린지
Apple HIG의 위젯 디자인 가이드라인을 따라, 실제로 동작하는 인터랙티브 날씨 위젯을 SwiftUI로 만들어봅니다.
5단계 성장고리를 따라가며 위젯 개발의 전 과정을 마스터합니다.
📖 Ring 1 — HIG 위젯 가이드라인
Apple은 위젯을 "미니 앱"이 아니라 앱의 핵심 정보를 홈 화면에 투영하는 것이라고 정의합니다. 사용자는 하루 평균 90번 이상 홈 화면을 보지만, 머무는 시간은 몇 초에 불과합니다.
1. Glanceable (한눈에 파악)
사용자가 몇 초 안에 핵심 정보를 파악할 수 있어야 합니다.
2. Relevant (시의적절)
시간, 장소, 사용자의 상황에 맞는 정보를 보여주세요.
3. Personalized (개인화)
위젯 설정을 통해 사용자가 커스터마이징할 수 있게 하세요.
✅ Do
- 콘텐츠가 주인공 — 큰 숫자, 명확한 아이콘
- 크기별로 다른 레이아웃 제공
- 다크모드/틴트 자동 대응
- containerBackground로 배경 처리
❌ Don't
- 작은 크기에 너무 많은 정보
- 로딩 스피너 표시
- 앱 아이콘/이름 반복
- 실시간 초 단위 업데이트
🏗️ Ring 2 — Widget Extension 기본 구조
WidgetKit은 TimelineProvider → TimelineEntry → View의 3계층 구조로 동작합니다.
Step 2-1: 날씨 데이터 모델
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와 통합되어 사용자가 위젯을 커스터마이징할 수 있습니다.
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 위젯 뷰
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 위젯 — 시간별 예보
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: 날씨 기반 그래디언트 배경
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: 인터랙티브 새로고침 버튼
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
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: 최종 위젯 구성
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를 따르는 완전한 날씨 위젯이 완성되었습니다!
🏆 보너스 챌린지
🔥 더 도전해보세요!
기본 챌린지를 완료했다면, 아래 보너스 과제로 실력을 한 단계 더 끌어올리세요.
- watchOS 위젯: Watch 컴플리케이션으로 확장
- StandBy 모드: iOS 17 StandBy 디스플레이 대응
- 위젯 번들: 여러 위젯을 WidgetBundle로 묶기
- 실제 WeatherKit API: Mock 데이터를 실제 API로 교체