호댕의 iOS 개발

[iOS] Widget 알아보기 (+ AppExtension) 본문

Software Engineering/iOS

[iOS] Widget 알아보기 (+ AppExtension)

호르댕댕댕 2022. 3. 17. 22:43

 

iOS 14 이후부터 위젯 기능을 사용할 수 있게 되었다.

(물론... 안드로이드에선 진작 있긴 했지만... )

 

 

위 아이패드 화면에서 흰 색 테두리로 표시해놓은 부분이 위젯이다. 

이를 통해 사용자에게 앱에서 중요한 내용을 홈 화면에서 상시 노출을 할 수 있는 것이다. 

 

일단 위젯도 AppExtension의 한 종류인 만큼 App Extension에 대해 먼저 알아보자. 

 

🛠 App Extension

🔸 App Extension 추가해보기 

이를 통해 홈 화면에 위젯으로 앱을 표시하거나, 액션 시트에 새 버튼을 추가하거나, 사진 앱 내 사진 필터 제공 등의 작업을 수행할 수 있다. 

이런 App Extension은 Xcode의 File > New > Target을 누르면 추가를 해줄 수 있다.

 

이렇게 해주면 이렇게 다양한 App Extension을 만날 수 있다. 

 

여기서 원하는 Extension을 선택하여 추가해주면 된다. 

 

🔸 앱과 앱 익스텐션의 관계

메세지에서 볼 수 있는 앱 익스텐션

 

위 사진처럼 앱에서 저렇게 익스텐션을 찾아볼 수 있다. 위 사진은 메세지에 있는 App Extension으로 만약 유튜브를  누르게 될 경우 본인의 계정이 연동이 되면서 알고리즘도 동일하게 뜨게 된다. 

 

그렇다면 앱과 앱 익스텐션은 어떤 관계를 가지고 있을까?

 

앱 익스텐션도 본인의 라이프사이클을 가지기 때문에 앱에 종속적인 것은 아니다. 

위 사진처럼 

1. 사용자가 App Extension을 선택하면 

2. 시스템에서 App Extension을 실행하고 

3. App Extension의 코드가 실행되며 

4. 사용이 종료되면 시스템이 App Extension을 죽이게 된다. 

 

즉, 앱과 동일한 라이프사이클을 가지지 않고 독자적인 라이프 사이클을 가지는 것이다. 

 

 

만약 메세지 앱의 App Extension으로 유튜브가 있다고 생각을 해보자. 

 

그러면 Host App은 메세지 앱, App Extension을 통해 실행한 Containg App은 유튜브가 된다. App Extension은 유튜브의 App Extension이 되는 것이다. 

 

하지만 App Extension과 Containing App은 상호 간에 직접적인 소통을 하진 못한다. 일반적으로 App Extension이 실행되는 동안 Containing App은 실행이 되지 않는다. 

 

Host App과 App Extension 사이의 소통만 있을 뿐이다. 

 

그럼 어떻게 Containing App의 정보를 App Extension이 알고 있는 것일까?

이는 Shared Resources를 활용한다. 

 

App Group을 사용하는 것이 일반적이나 이를 사용하기  위해선 개발자 계정이 따로 존재해야 한다🥲

 

 

📱 Widget

그럼 App Extesion 중 하나인 Widget에 대해 좀 더 자세히 알아보자. 

 

위젯은 홈화면에서 실행이 되는 만큼 호스트 앱은 따로 존재하지 않는다. 

따라서 일반적인 App Extension과는 약간 관계가 다르다. 

 

일단 위젯은 앞서 설명했던 File > New > Target에서 추가할 수 있다. 

 

위에 말했던 것처럼 들어가면 이렇게 위젯을 추가할 수 있는 화면을 볼 수 있다. 

 

여기서 빨간색으로 표시된 Include Configuration Intent를 체크할 경우 사용자에게 입력을 따로 받을 수 있는 IntentConfiguration으로 생성이 되며 체크를 따로 하진 않을 경우 사용자가 따로 입력은 할 수 없는 StaticConfiguration으로 생성이 된다. 

  • StaticConfiguration: 단순히 보여주기만 하는 위젯으로 대표적으로 주식 관련 위젯이나 뉴스 위젯이 있다. 
  • IntentConfiguration: SiriKit으로 Custom Intent를 정의할 수도 있다. 대표적인 예는 택배 조회 위젯으로 트래킹 번호를 받을 수 있도록 한다.

 

이때 Product Name은 기존 프로젝트 이름과는 달라야 하며, 그냥 'Widget'이라고 지을 경우 Widget이라는 프로토콜과 이름이 동일하여 아래와 같은 에러가 발생한다. 

'Widget' is annotated with @main and must provide a main static function of type () -> Void or () throws -> Void.

Inheritance from non-protocol type 'Widget’

 

이렇게 위젯을 추가해주면 자동으로 위젯에 필요한 코드가 생성된다.

 

🔸 위젯 구성하기

@main
struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("ddd")
        .description("This is an example widget.")
    }
}

일단 본인이 작성한 Product Name으로 위젯 구조체가 생성이 되고 여기에는 kind와 body로 구성이 되어 있다. 

 

Kind의 경우 위젯을 식별하는 String으로 위젯이 보여주는 것을 설명하는 네이밍이 좋다. 

 

body의 경우 일단 위에 말했던 Include Configuration Intent를 체크하지 않아 StaticConfiguration으로 되어 있다. 여기에는 kind와 함께 provider를 적는데 이 provider의 경우 위젯을 언제 업데이트할 지를 TimeLineEntry 타입을 통해 WidgetKit에 알려준다.

 

여기서 @main attribute의 경우 해당 Widget Extension의 진입점이 해당 구조체임을 보여주는 것이다. 

 

그리고 밑에 있는 메서드들도 살펴보자. 

  • configurationDisplayName: 위젯 추가를 하는 화면에서 위젯의 제목으로 나오게 된다. 
  • description: 해당 위젯에 대한 설명으로 나온다. 

 

아까 body에서 등장했던 Provider도 위에 자동으로 생성이 되어 있다.

struct Provider: TimelineProvider {
    let colors: [UIColor] = [.blue, .red, .green, .brown, .yellow]
    
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), color: colors.randomElement() ?? .yellow)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), color: colors.randomElement() ?? .yellow)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for minuteOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, color: colors.randomElement() ?? .yellow)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

🔹 placeholder(context:)

placeholder의 경우 핸드폰을 껐다가 켜거나 서버에서 데이터를 받지 못한 경우 앱에서 정보를 로드하지 못한 경우가 발생할 수 있다. 

이런 상황에서 데이터를 받지 않고도 사용자에게 해당 위젯이 어떤 역할인지를 보여주기 위해 사용되는 것이다. 

 

따라서 다른 getSnapshot 메서드나 getTimeline 메서드의 경우 비동기로 처리를 하지만 해당 함수는 동기 함수이다.

 

🔹 getSnapshot(context:completion:)

위젯을 추가할 때 보여지는 프리뷰 화면을 구성한다. 

 

🔹getTimeLine(context:completion:)

해당 함수를 통해 언제 위젯을 업데이트할 지 정해주게 된다.

위 코드에선 byAdding을 .minute으로 두고 있고 현재 시간부터 0~5까지 이를 반복하고 있다. 

 

즉 지금부터 1분 주기로 업데이트를 진행하며 6번 반복이 끝나게 되면 Timeline에 지정해 놓은 policy대로 앞으로 어떻게 반복을 할 지 정하게 된다. 

 

<Policy의 종류>

  • atEnd() : 업데이트를 정해진 Timeline대로 진행한 뒤 다시 반복해서 해당 업데이트를 진행하게 된다. 
  • after(_ date: Date) : 업데이트를 정해진 Timeline대로 진행한 뒤 정해진 Date 뒤에 해당 업데이트를 다시 진행하게 된다.
  • never() : 업데이트를 정해진 Timeline대로 진행한 뒤 다시 업데이트를 하지 않는다.

 

여기서 해당 Timeline 동안 어떤 것을 전달할 지는 SimplEntry에서 정해줄 수 있다. 

struct SimpleEntry: TimelineEntry {
    let date: Date
    let color: UIColor
}

이때 date는 반드시 전달을 해줘야 한다. 

 

또한 Widget의 View구성은 EntryView에서 정해줄 수 있다. 

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack{
            Text("TODO")
                .font(.headline)
            
            HStack{
                Text("업데이트 시간")
                Text(entry.date, style: .time)
            }
            
            ZStack {
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.gray)
                
                VStack {
                    let title = DummyProjects.projects[0].projectName
                    let body = DummyProjects.projects[0].description
                    Text(title)
                        .font(.title)
                    Text(body)
                        .font(.body)
                }
            }
            
            ZStack {
                RoundedRectangle(cornerRadius: 10)
                    .foregroundColor(.gray)
                
                VStack {
                    let title = DummyProjects.projects[1].projectName
                    let body = DummyProjects.projects[1].description
                    Text(title)
                        .font(.title)
                    Text(body)
                        .font(.body)
                }
                
            }
        }.padding()
            .foregroundColor(Color(entry.color.cgColor))
    }
}

 

🔸 위젯 작동 방식

그럼 마지막으로 Widget이 어떤 방식으로 동작을 하는지 살펴보자. 

 

Comments