๐ค ๋๋ง์ ๋ ์จ ์์ ฏ ๋ง๋ค๊ธฐ
์ฑ๋ฆฐ์ง
Following Apple HIG Widget Design Guidelines, we'll build a working interactive weather widget with SwiftUI.
Follow the 5-stage growth path to master the complete widget development process.
๐ Ring 1 โ HIG ์์ ฏ ๊ฐ์ด๋๋ผ์ธ
Apple์ ์์ ฏ์ "๋ฏธ๋ ์ฑ"์ด ์๋๋ผ ์ฑ์ ํต์ฌ ์ ๋ณด๋ฅผ ํ ํ๋ฉด์ ํฌ์ํ๋ ๊ฒ. Users check their home screen over 90 times a day on average, but only stay for a few seconds.
1. Glanceable (ํ๋์ ํ์
)
Users should be able to grasp key information within seconds.
2. Relevant (์์์ ์ )
Show information relevant to the time, place, and user context.
3. Personalized (๊ฐ์ธํ)
Let users customize through widget configuration.
โ Do
- ์ฝํ ์ธ ๊ฐ ์ฃผ์ธ๊ณต โ ํฐ ์ซ์, ๋ช ํํ ์์ด์ฝ
- ํฌ๊ธฐ๋ณ๋ก ๋ค๋ฅธ ๋ ์ด์์ ์ ๊ณต
- ๋คํฌ๋ชจ๋/ํดํธ ์๋ ๋์
- containerBackground๋ก ๋ฐฐ๊ฒฝ ์ฒ๋ฆฌ
โ Don't
- ์์ ํฌ๊ธฐ์ ๋๋ฌด ๋ง์ ์ ๋ณด
- ๋ก๋ฉ ์คํผ๋ ํ์
- ์ฑ ์์ด์ฝ/์ด๋ฆ ๋ฐ๋ณต
- ์ค์๊ฐ ์ด ๋จ์ ์ ๋ฐ์ดํธ
๐๏ธ Ring 2 โ Widget Extension Basic Structure
WidgetKit operates on a 3-layer architecture: TimelineProvider โ TimelineEntry โ View.
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: Implement AppIntentTimelineProvider (iOS 17+)
iOS 17๋ถํฐ AppIntentTimelineProvider integrates with App Intents, letting users customize widgets.
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 ์ฒดํฌํฌ์ธํธ
The TimelineProvider fetches weather data, creates entries, and sets a 15-minute refresh policy.
๐จ Ring 3 โ HIG ์ค์ UI ๋์์ธ
Implement size-optimized layouts and gradient backgrounds based on weather conditions.
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 ์ฒดํฌํฌ์ธํธ
Completed optimal layouts for Small/Medium sizes and gradient backgrounds by weather condition.
โก Ring 4 โ ๋ฐ์ดํฐ & ์ธํฐ๋์
From iOS 17, you can add Button and Toggle to widgets for interactivity.
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 โ ์ค์ & ํผ์ค๋๋ผ์ด์ฆ
With App Intent Configuration, let users choose a city and support Lock Screen widgets.
Step 5-1: City Selection 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) } } }
Challenge Complete!
You've completed all 5 growth stages. A fully HIG-compliant weather widget is finished!
๐ ๋ณด๋์ค ์ฑ๋ฆฐ์ง
๐ฅ ๋ ๋์ ํด๋ณด์ธ์!
Completed the basic challenge? Level up with these bonus tasks.
- watchOS ์์ ฏ: Watch ์ปดํ๋ฆฌ์ผ์ด์ ์ผ๋ก ํ์ฅ
- StandBy ๋ชจ๋: iOS 17 StandBy ๋์คํ๋ ์ด ๋์
- ์์ ฏ ๋ฒ๋ค: ์ฌ๋ฌ ์์ ฏ์ WidgetBundle๋ก ๋ฌถ๊ธฐ
- ์ค์ WeatherKit API: Mock ๋ฐ์ดํฐ๋ฅผ ์ค์ API๋ก ๊ต์ฒด