야매 iOS

[iOS/Swift] How to use iOS WidgetKit / 위젯 만들기

the Cosmos 2023. 2. 19. 10:11

Widget

정보를 표시하고 앱기능의 빠른 접근을 가능케 하는 작은 인터페이스 요소

Basic LifeCycle of Widget

  1. 뷰 생성 : 위젯이 홈스크린에 추가되면 시스템은 위젯의 뷰 계층을 생성하고 필수적인 환경설정을 세팅한다
  2. 데이터 불러오기 : 위젯은 그의 콘텐츠 및 디스플레이에 필요한 데이터를 불러온다.
    • 네트워크로 데이터 가져오기, 로컬 저장소 접근, 부모 앱(메인 앱)과의 communication
  3. 화면 업데이트 : 불러온 데이터로 위젯 뷰를 업데이트하고 위젯은 홈스크린에 보인다
  4. 유저 인터렉션 : 유저가 위젯과 interact 하면 위젯의 설정에 따라 시스템은 부모 앱(메인 앱)을 실행하거나 미리 지정된 동작을 수행한다
  5. 백그라운드 리프레시 : TimelineProvider API를 사용해 백그라운드에서 주기적으로 위젯의 데이터를 새로 고침
  6. 삭제 : 위젯이 홈 스크린에서 제거되면 시스템은 위젯의 자원과 진행되고 있는 백그라운드 리프레시를 중단한다

위젯 종류

StaticConfiguration

  • 유저가 구성 가능한 프로퍼티가 없는 위젯
  • 위젯을 꾹 눌렀을 때 위젯 편집이 뜨지 않는 위젯

IntentConfiguration

  • 유저가 구성 가능한 프로퍼티가 있는 위젯
  • 위젯을 꾹 눌렀을 때 위젯 편집이 뜨는 위젯

→ SiriKit Intent Definition File을 사용해 IntentConfiguration을 초기화한다.

위젯 실행 조건

💡 위젯이 위젯 갤러리에 뜨기 위해서 먼저 위젯을 갖고 있는 앱을 한 번이라도 실행시켜야 한다.

위젯 interaction

  • 위젯은 오직 read-only 정보만 보여준다
  • 스크롤이나 스위치 같이 interactive한 행동을 지원하지 않는다

Widget 생성

앱에 위젯을 추가하기 위해서 최소한의 설정이 필요하다

  • configuration & 유저 인터페이스 스타일

위젯 타겟 추가

1. File > target > WidgetExtension으로 타겟 추가

2. 적절한 이름 선택

3. 사용자가 직접 설정할 수 있는 위젯을 만들고 싶다면 Include Configuration Intent checkbox 활성화

4. Finish

위젯 생성

@main
struct TempWidget: Widget {
	var body: some WidgetConfiguration {
		IntentConfiguration(
			kind: "com.~~~.temp-widget"
			provider: TempWidgetProvider(),
			intent: TempWidgetIntent()
		) { entry in
				WidgetView(entry)
		}
	} 
	// 추가 설정 가능
	.configurationDisplayName("") // 위젯 이름
	.description("") // 위젯에 대한 설명
	.supportedFamilies([.systemSmall, .systemMedium]) // 지원하는 위젯 크기
}

위젯을 생성할 때 WidgetConfiguration을 생성해야 한다

  • IntentConfiguration
  • StaticConfiguration

Configuration 구성요소

  1. kind
    • 여러 위젯 중 위젯을 구분할 수 있는 이름
    • 부모 앱(메인 앱)에서 위젯에 접근할 때 WidgetCenter를 사용해 kind로 지정한 위젯 접근 가능
  2. provider
    • 위젯을 어떤 방식으로 어떤 주기로 업데이트할 것인지에 대한 정보를 가짐
    • 위젯에서 필요한 정보인 Entry를 생성함
  3. intent
    • 유저가 구성 가능한 프로퍼티가 있는 Intent를 넘겨줌
  4. content closure
    • Entry를 parameter로 SwiftUI로 구성된 위젯 화면을 그린다

Provider

위젯을 업데이트하기 위해 데이터를 제공하는 역할을 한다

Provider는 Entry를 사용해 앱을 업데이트한다

struct TempWidgetProvder: IntentTimelineProvider {

	func placeholder(in context: Context) -> TempWidgetEntry {
		return TempWidgetEntry(date: Date(), name: "name", age: 0)
	}
	
	/*
위젯의 스냅샷을 그린다
- 주로 위젯을 화면에 추가할 때 또는 갤러리에 추가할 때 
		
Context = 위젯이 렌더링되는 방법에 대한 세부 정보
- isPreview: Widget 갤러리에서 위젯이 표시된건지
- Family: 위젯 크기
- environmentVariants: 환경 변수들
- 기타 등등
            
context.isPreview를 통해 위젯 갤러리에서 보여지는 지 알 수 있으므로 프리뷰를 어떻게 보여줄 것인지 처리할 수 있다.
	*/

	func getSnapshot(
		for configuration: Intent,
     	in context: Context, 
		completion: @escaping (TempWidgetEntry) -> Void
    ) {
		let entry = TempWidgetEntry(date: Date(), name: "name", age: 0)
		return entry
	}
	/*
최초의 스냅샷을 요청한 후에 WidgetKit은 타임라인을 얻기 위해 getTimeline(in: completion:)을 요청한다
timeline은 하나 이상의 timeline entry를 가질 수 있다;

policy: atEnd, never, after() 
- atEnd: timeline의 entry 순서대로 진행 후 끝나면 Provider에게 새로운 entry를 요청
- never: timeline의 entry 순서대로 진행 후 한 사이클이 끝나면 끝 앱에서 처리해줘야 함
- after() 괄호 안에 시간 이후에 새로운 타임라인 갱신
	*/
	func getTimeline(
		for configuration: Intent, 
		in context: Context,
		completion: @escaping (Timeline<TempWidgetEntry>
    ) -> Void {
		let entry = TempWidgetEntry(date: Date(), name: "name", age: 0)
		let timeline = Timeline(entries: [entry], policy: .after(nextDate))
		// network Response가 필요하다면 network response completion handler 내부에서 completion 호출
		completion(timeline)
	}
}

Entry

/*
TimelineEntry 프로토콜을 준수해야 하므로 date 필드는 필수로 포함되어야 하고
그 외 Widget을 구현할 때 필요한 데이터 필드 추가
*/

struct TempWidgetEntry: TimelineEntry {

	var date: Date
	var name: String
	var age: Int
}

UserInteraction 처리

WidgetURL

위젯 뷰의 modifier로 WidgetURL이 설정되어 있으면 위젯의 interaction이 감지되는 순간 URL로 이동한다

  • scheme이 적용된 URL을 사용하면 앱 내부 지정한 뷰로 이동

Widget의 뷰 계층이 두 개 이상의 widgetURL modifier가 있다면 동작이 이상해질 수 있다

Link

WidgetFamily.systemMedium, WidgetFamily.systemLarge, WidgetFamily.systemExtraLarge에서만 사용할 수 있다.

Link와 WidgetURL 모두 기능을 동일하지만 Link는 개별 element에 적용되고 WidgetURL은 그 외 나머지 영역에 적용된다.

WidgetURL 또는 Link가 위젯 뷰에 없을 때

부모뷰 (containing app)을 실행시킨다

  • NSUserActivity를 onContinueUserActivitgy(_: perform) 전달
  • NSUserActivity를 application(_:continue:restorationHandler) 전달

NSUserActivity의 userInfo dictionary는 어떤 위젯과 interact 했는지에 대한 정보가 담겨있다.

  • WidgetCenter.UserInfoKey를 이용해 값에 접근할 수 있다

IntentConfiguration을 사용했을 때

  • NSUserActivity의 interaction 프로퍼티에 widget의 INIntent를 갖는다.

Widget 최신화

Budget

버젯은 하루동안 위젯이 업데이트할 수 있는 횟수

  • 빈번한 업데이트로 발생할 수 있는 성능 저하와 배터리 수명 문제를 막기 위해서 업데이트 하는 수를 OS에서 제한

Budget 계산 요소

  • 위젯이 사용자에게 보이는 시간과 빈도
  • 위젯의 마지막 갱신 시간

Budget 적용

WidgetKit이 각 활성화된 위젯에 대해 budget을 가진다

Budget은 24시간 동안 적용되며 WidgetKit에 대해 budget이 재계산되어 적용된다

최신화 빈도

위젯은 하루에 40~70번 업데이트

위젯은 15~50분마다 한 번씩 업데이트

→ Provider에 제공하는 Timeline은 위젯에게 참고용으로 적용되는 것 같다. 실제로 그 시간에 위젯이 업데이트되지 않는다

💡 며칠 동안 시스템은 유저의 행동을 학습해야 되므로 학습기간 동안 위젯이 평소보다 많이 reload할 수 있다.

Budget에 포함되지 않는 update

  • Widget의 containing app(부모앱)이 foreground에 있을 때
  • Widget의 containing app(부모앱)이 활성화된 오디오 또는 네비게이션 세션을 가질 때
  • 시스템 설정 바뀌었을 때 (System locale(언어 설정))
    • 자동으로 앱 업데이트
  • 동적 타입(Intent) 또는 접근성 설정 바꼈을 때

WidgetKit이 timeline과 관계없이 update 해줄 때

  • 유저의 접근이 드문 홈화면에 위젯이 위치할 때
    • 위젯의 reload 빈도를 줄이고 위젯이 있는 홈화면으로 유저가 진입하면 reload
  • 위젯이 위치 정보 서비스를 사용할 때
    • 위치정보가 급격하게 변경되면 reload

💡 앞으로 어떤 일이 일어날 지에 대한 정보가 있으면 그에 대한 timeline을 지정하는 것이 좋다

예상 가능한 이벤트에 대해 타임라인 생성

날씨 위젯이 날씨 정보를 한 시간마다 업데이트할 때 이 업데이트 주기는 예정되어 있다(한 시간).

WidgetKit은 Provider로부터 타임라인을 받고 타임라인을 통해 언제 위젯을 업데이트하고 어떤 정보로 위젯을 업데이트할 것인지에 대한 정보를 얻는다

timeline은 TimelineEntry 객체의 배열과 refresh policy를 담고 있다.

  • 각 Entry는 날짜와 시간, 위젯을 보여주기 위해 필요한 데이터를 포함한다.
  • refresh policy = atEnd, never, after()

WidgetKit이 처음 타임라임을 요청하면 Provider는 entry 4개 담겨있는 타임라인을 반환한다.

  • 새로운 entry가 들어오면 widget의 content closure를 실행시키고 그 결과를 화면에 보여준다

타임라인의 마지막 entry가 끝나면 Provider에게 새로운 타임라인을 요청한다.

Provider로부터 새로운 타임라인을 after에 담긴 시간 텀 이후에 받아옴

App → WidgetKit notification

특정 위젯에 접근하기

변경사항이 있을 때 App은 WidgetKit에 새로운 타임라인을 만들어 달라고 요청할 수 있다

WidgetCenter.shared.reloadTimelines(ofKind: ~~~) \\(WidgetConfiguration에 있는 kind)

위젯 리스트 중 적합한 위젯 업데이트

// APP에 등록된 모든 위젯 Configuration(정보)를 갖고 옴
WidgetCenter.shared.getCurrentConfigurations { result in
    guard case .success(let widgets) = result else { return }

    if let widget = widgets.first(
        where: { widget in
            let intent = widget.configuration as? TempIntent
            return intent?.name == writtenName
        }
    ) {
        WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
    }
}

편집 가능한 위젯 만들기

유저들에게 관련성 있는 정보의 접근성을 높이기 위해 위젯은 편집할 수 있는 프러퍼티들을 제공한다.

  • 날씨 위젯에서 보고 싶은 지역 고를 수 있도록

Intent 파일 추가

WidgetExtension 타겟에 File > new > Sirikit Intent Defintion 파일 생성

  • 파일이 포함되어 있는 타겟에 Intent’s class name이 들어가 있는지 확인. class name은 intent 파일을 클릭하고 오른쪽 interface를 보면 알 수 있음

💡 Intent 파일을 containing app (부모 앱)에도 추가해줘야 한다. app과 프레임워크 간의 중복을 방지하기 위해 타겟 멤버십에 No Generated Classes for app target을 설정해 준다.

Configurable Property 추가

1. Intent Definition 내부에서 configurable property 추가

2. IntentTimelineProvider를 사용해 유저의 선택을 timeline entry에 포함할 수 있음

// IntentTimelineProvider
func getTimeline(
	for configuration: Intent, 
	in context: Context, 
	completion: @escaping (Timeline<Entry>) -> Void
) {
	// intent에 있는 configurable property 접근 가능
	context.name
	context.age
}

// TimelineProvider
func getTimeline(
	in context: Context,
	completion: @escaping (Timeline<Entry>) -> Void) {
}

3.Custom Intent의 이름을 변경하고 Parameter를 추가한다

  • 이 Parameter가 IntentTimelineProvider에서 Intent를 통해 접근할 수 있는 값이다.
  • 이 프러퍼티의 순서대로 위젯에서 보인다.

4. 새로운 타입이 필요하다면 새로운 타입을 하나 생성하고 필요한 프로퍼티를 모두 정의한다.

Intents Extension 프로젝트에 추가하기

위에서 정의한 프로퍼티 리스트를 동적으로 보여주기 위해서 IntentsExtension을 추가해줘야 한다.

User가 위젯을 수정한다면 위젯킷은 IntentExtension을 불러와서 동적 정보들을 제공한다

  1. File > New > Target에서 Intents Extension 선택
  2. 이름 입력 및 Starting Point를 None으로 지정
  3. Xcode가 new scheme을 활성화할 것인가에 대한 prompt가 뜨면 Activate 선택
  4. Intents Extension의 General Tab에 들어가서 entry in Supported Intents 섹션에 Intent의 클래스 이름을 넣어준다
  5. Widget Extension에 있는 파일을 Intents Extension으로 옮겨준다

💡 IntentDefiniton은 Containing app, widget extension, intents extension에 모두 포함되어야 한다

동적 값들을 제공하기 위해 Intent Handler 구현

유저가 동적 값을 제공하는 custom intent를 사용해 위젯을 편집하고자 할 때 시스템은 해당 값들을 제공하는 객체가 필요하다

  • Intents Extension에 해당 Intent에 대한 handler를 구현해 그로부터 정보를 얻어오도록 한다
  • Intents Extension을 생성하면 IntentHandler.swift 파일이 만들어진다
  • 미리 생성한 Custom Intent에 대해 자동으로 (IntentName)Handling이라는 프로토콜이 만들어진다
  • IntentHandler가 자동으로 생성된 프로토콜을 conform 하면서 요청을 들어줄 수 있다.

프로퍼티에 대한 동적 리스트 제공

프로퍼티에 대한 옵션들을 제공하기 위해 provide[Type]OptionCollection(for:with:)을 구현해야 한다

func provideNameOptionCollection(for intent: TempWidgetIntent) async throws -> INObjectCollection<CustomType> {
}

func provideAgeOptionCollection(for intent: TempWidgetIntent) async throws -> INObjectCollection<CustomType> {
}

사용하면서 알게된 것

폰이 꺼졌을 때도 위젯 update가 되는가?

폰을 Mac에 연결하고 폰을 끈 채로 하루를 놔둬봤다.

로그를 확인해 보니 폰이 꺼져 있어도 위젯이 업데이트할 때 호출되는 getTimeline이 호출되는 것을 알 수 있었다.

원하는 시간대에 위젯이 업데이트되지 않음

위젯이 업데이트되는 정확한 시간대는 개발자가 지정할 수 없는 것으로 보인다.

여러 refreshOption 중에 .after()가 그나마 가장 지정한 시간대에 update될 수 있다는 공식문서를 봤지만 확인해 보니 그러지도 않는다…

WidgetCenter.shared.reloadTimelines(ofKind)

애플 공식문서에 보면 refreshPolicy를 .never로 설정한 위젯에 대해 reloadTimelines를 하기 때문에 .never에만 적용된다는 생각을 가졌었다.

하지만 refreshPolicy를 .after로 설정한 위젯애 대해서도 잘 동작하는 것 확인

Widget Image Fetch

image URL을 받아와 kingfisher로 이미지를 보여줬었다

하지만 처음에는 이미지가 안 보이다가 해당 옵션을 다시 선택하면 이미지가 보이는 문제가 반복적으로 발생

확인해 보니까 위젯은 앱이라기보다 스냅샷에 가깝다.

정적인 이미지이기 때문에 내부적으로 비동기 이미지 처리가 실행되지 않았다.

해당 옵션을 다시 선택하면 이미지가 보이는 것은 첫 번째 시도에서 캐시한 이미지를 그대로 보여주는 것이기 때문에 가능했었다.

이를 해결하기 위해선 Provider에서 타임라인을 제공할 때 entry에 이미지를 전달해 주거나 이미지가 캐시 되어 있는 상태여야 했다.

해결방안

let sources: [Source] = urls.map {
	guard let url = URL(string: $0) else { return nil }
	return .network(ImageResource(downloadURL: url, cacheKey: $0))
}
.compactMap { $0 }

// source들에 이미지를 모두 불러오고 캐시가 되었다면 Provider에게 알려준다.
ImagePrefetcher(
	sources: sources,
	options: [.waitForCache] 
) { _, _, _, in
	// provider로 전달

}
.start()

출처

https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension

https://develo per.apple.com/documentation/WidgetKit/Keeping-a-Widget-Up-To-Date

https://developer.apple.com/documentation/widgetkit/making-a-configurable-widget