iOS WidgetKit + Android Glance: 위젯 개발 완전 가이드

모바일 개발

WidgetKitAndroid GlanceiOS 위젯홈 화면 위젯SwiftUI

이 글은 누구를 위한 것인가

  • 앱 리텐션을 높이기 위해 홈 화면 위젯을 추가하려는 모바일 개발자
  • 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가지 핵심 정보만 보여주고, 탭하면 앱으로 연결하는 패턴이 가장 효과적이다.