๐ค ๋๋ง์ ๋ ์จ ์์ ฏ ๋ง๋ค๊ธฐ
์ฑ๋ฆฐ์ง
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๋ก ๊ต์ฒด