이 글은 누구를 위한 것인가
- 앱 리텐션을 높이기 위해 홈 화면 위젯을 추가하려는 모바일 개발자
- iOS WidgetKit과 Android Glance 중 어떻게 시작할지 모르는 팀
- 위젯 타임라인 업데이트와 딥링크 연동이 필요한 개발자
들어가며
홈 화면 위젯은 앱을 열지 않아도 핵심 정보를 보여준다. 날씨 앱의 위젯은 앱보다 더 자주 보인다. 이커머스 앱의 주문 현황 위젯, 피트니스 앱의 오늘 목표 위젯 — 위젯은 앱 리텐션의 가장 강력한 도구 중 하나다.
이 글은 bluefoxdev.kr의 모바일 위젯 개발 가이드 를 참고하고, iOS/Android 플랫폼별 실전 구현 관점에서 확장하여 작성했습니다.
1. 플랫폼별 위젯 구조
[iOS WidgetKit]
├── Widget (위젯 정의)
├── TimelineProvider (데이터 제공)
│ ├── placeholder() — 초기 자리표시자
│ ├── getSnapshot() — 미리보기
│ └── getTimeline() — 실제 타임라인
├── TimelineEntry (단일 시점 데이터)
└── View (SwiftUI 뷰)
[Android Glance]
├── GlanceAppWidget (위젯 정의)
├── GlanceAppWidgetReceiver (브로드캐스트)
├── GlanceStateDefinition (상태 저장)
└── Content() (Composable 뷰)
[위젯 크기]
iOS: systemSmall, systemMedium, systemLarge, systemExtraLarge
accessoryCircular, accessoryRectangular (잠금화면)
Android: 2×2, 4×2, 4×4 (셀 단위 — 기기마다 픽셀 다름)
2. iOS WidgetKit 구현
import WidgetKit
import SwiftUI
// 타임라인 엔트리 (한 시점의 데이터)
struct OrderEntry: TimelineEntry {
let date: Date
let order: OrderStatus?
}
struct OrderStatus {
let id: String
let productName: String
let status: String // 배송중, 배송완료 등
let estimatedDelivery: String?
}
// 타임라인 프로바이더
struct OrderTimelineProvider: TimelineProvider {
// 위젯 갤러리 자리표시자 (즉시 반환)
func placeholder(in context: Context) -> OrderEntry {
OrderEntry(date: .now, order: OrderStatus(
id: "placeholder",
productName: "상품명",
status: "배송 중",
estimatedDelivery: "오늘 도착 예정"
))
}
// 위젯 미리보기 스냅샷
func getSnapshot(in context: Context, completion: @escaping (OrderEntry) -> Void) {
fetchLatestOrder { order in
completion(OrderEntry(date: .now, order: order))
}
}
// 타임라인 — 언제 어떤 데이터를 보여줄지
func getTimeline(in context: Context, completion: @escaping (Timeline<OrderEntry>) -> Void) {
fetchLatestOrder { order in
let entry = OrderEntry(date: .now, order: order)
// 30분마다 갱신
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
private func fetchLatestOrder(completion: @escaping (OrderStatus?) -> Void) {
// App Group으로 앱과 데이터 공유
let defaults = UserDefaults(suiteName: "group.com.myapp.shared")
if let data = defaults?.data(forKey: "latestOrder"),
let order = try? JSONDecoder().decode(OrderStatus.self, from: data) {
completion(order)
} else {
completion(nil)
}
}
}
// 위젯 뷰
struct OrderWidgetView: View {
let entry: OrderEntry
@Environment(\.widgetFamily) var widgetFamily
var body: some View {
Group {
if let order = entry.order {
switch widgetFamily {
case .systemSmall:
SmallOrderView(order: order)
case .systemMedium:
MediumOrderView(order: order)
default:
SmallOrderView(order: order)
}
} else {
EmptyOrderView()
}
}
.widgetURL(URL(string: "myapp://orders/\(entry.order?.id ?? "")"))
}
}
struct SmallOrderView: View {
let order: OrderStatus
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Label("주문 현황", systemImage: "shippingbox.fill")
.font(.caption)
.foregroundStyle(.secondary)
Text(order.productName)
.font(.callout.bold())
.lineLimit(2)
Spacer()
Text(order.status)
.font(.caption)
.foregroundStyle(statusColor(order.status))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(statusColor(order.status).opacity(0.1))
.cornerRadius(8)
if let delivery = order.estimatedDelivery {
Text(delivery)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding()
}
func statusColor(_ status: String) -> Color {
switch status {
case "배송완료": return .green
case "배송 중": return .blue
case "준비 중": return .orange
default: return .gray
}
}
}
// iOS 17+ 인터랙티브 위젯 (버튼 클릭)
struct InteractiveOrderWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "OrderWidget", provider: OrderTimelineProvider()) { entry in
OrderWidgetView(entry: entry)
}
.configurationDisplayName("주문 현황")
.description("최근 주문 배송 상태를 확인하세요")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
3. Android Glance 구현
import androidx.glance.*
import androidx.glance.appwidget.*
import androidx.glance.layout.*
import androidx.glance.text.*
class OrderWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<Preferences>()
val productName = prefs[stringPreferencesKey("product_name")] ?: "주문 없음"
val status = prefs[stringPreferencesKey("status")] ?: ""
val orderId = prefs[stringPreferencesKey("order_id")] ?: ""
OrderWidgetContent(
productName = productName,
status = status,
orderId = orderId,
)
}
}
}
@Composable
fun OrderWidgetContent(
productName: String,
status: String,
orderId: String,
) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.padding(12.dp)
.clickable(
actionStartActivity<MainActivity>(
actionParametersOf(
ActionParameters.Key<String>("order_id") to orderId
)
)
),
verticalAlignment = Alignment.Vertical.CenterVertically,
) {
Text(
text = "주문 현황",
style = TextStyle(
fontSize = 12.sp,
color = GlanceTheme.colors.onSurfaceVariant,
)
)
Spacer(modifier = GlanceModifier.height(4.dp))
Text(
text = productName,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onBackground,
),
maxLines = 2,
)
Spacer(modifier = GlanceModifier.height(8.dp))
Box(
modifier = GlanceModifier
.background(statusColor(status))
.cornerRadius(8.dp)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = status,
style = TextStyle(fontSize = 12.sp, color = ColorProvider(Color.White))
)
}
}
}
fun statusColor(status: String): ColorProvider = when (status) {
"배송완료" -> ColorProvider(Color(0xFF4CAF50))
"배송 중" -> ColorProvider(Color(0xFF2196F3))
"준비 중" -> ColorProvider(Color(0xFFFF9800))
else -> ColorProvider(Color(0xFF9E9E9E))
}
// 위젯 리시버
class OrderWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = OrderWidget()
}
// 위젯 데이터 업데이트 (앱에서 호출)
suspend fun updateWidgetData(context: Context, order: Order) {
val glanceIds = GlanceAppWidgetManager(context)
.getGlanceIds(OrderWidget::class.java)
glanceIds.forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
prefs[stringPreferencesKey("product_name")] = order.productName
prefs[stringPreferencesKey("status")] = order.status
prefs[stringPreferencesKey("order_id")] = order.id
}
OrderWidget().update(context, glanceId)
}
}
마무리
홈 화면 위젯은 "앱을 열지 않아도 관계를 유지하는 도구"다. WidgetKit과 Glance 모두 SwiftUI/Compose 기반이므로 기존 모바일 개발자라면 진입 장벽이 낮다.
핵심은 위젯에 너무 많은 정보를 담지 않는 것이다. 사용자가 한 눈에 파악할 수 있는 1-2가지 핵심 정보만 보여주고, 탭하면 앱으로 연결하는 패턴이 가장 효과적이다.