Add configuration and intelligence to your widgets

영상 링크 → 링크

Meet WidgetKit 정리 → 링크

함께 보면 좋은 문서 → Making a Configurable Widget

Intro

이 영상에선 위젯을 Configurable하게 만드는 방법과 Configurable한 위젯이 어떻게 시스템을 더욱 영리한 방식으로 작동하는데 도움을 줄 수 있는지에 대해 알아볼 것이다.

다음은 영상에서 살펴볼 예제 앱이다.

카드별 결제 내역을 보여주는 앱으로 최근 결제 내역, 납부일 및 납부해야 할 총 금액을 나타내는 두 가지 위젯을 제공한다.

이번 영상에서 다룰 주제의 목차는 다음과 같다.

  1. The Basics
  2. Types of data entry
  3. Configuration experience
  4. System intelligence

제일 먼저 위젯에 설정을 추가하는 방법을 살펴볼 것이다. 그 다음으로 사용자에게 입력하도록 요청할 수 있는 정보의 유형에 대해 설명하고, 앱의 데이터로 인터페이스를 채울 수 있는 방법을 살펴볼 것 이다.

이후엔 어떻게 위젯의 제목, 설명을 지정하고 배경색을 설정할 수 있는지에 대해 살펴볼 것이다. 마지막으론 스마트 스택에서 시스템이 여러분이 개발한 위젯이 노출되어야 할 시간을 보다 더 정확하게 예측할 수 있도록 설정을 추가하는 방법에 대해 살펴볼 예정이다.

The Basics

위젯이 설정 가능하다면 위젯 뒷면에 사용자에게 입력을 요청할 수 있는 옵션들을 지정할 수 있고, 시스템은 이를 사용자에게 보여줄 것이다. 이런 옵션, 즉 사용자가 정보를 입력할 수 있는 요소들을 파라미터라고 한다.

예제 앱에선 Card 파리미터와 Category 파라미터를 지정하여 사용자가 원하는 카드의 원하는 카테고리의 최근 결제 내역을 위젯에 보여줄 수 있다.

이런 파라미터를 정의하기 위해 SiriShortcut에서 사용하고 있는 Intent를 사용한다. Intent에 추가한 파라미터는 위젯에 하나의 행으로 사용자에게 보여진다.

Xcode에서 Intent Definition 파일을 통해 Intent를 선언할 수 있다. 이 파일에는 Intent와 더불어 그들의 파라미터 등도 포함된다. 이 파일을 통해 시스템은 Intent의 정보를 읽을 수 있다.

Intent를 정의하면 Xcode는 정의한 Intent와 이에 포함되는 파라미터를 프로퍼티로 갖는 클래스를 생성한다.

이 클래스의 인스턴스는 런타임 중 위젯 익스텐션에 전달되며 이를 통해 위젯은 사용자가 무엇을 설정했고 사용자에게 무엇을 보여주어야 하는지를 알 수 있다.

Types of data entry

위젯 설정에선 다양한 데이터 타입을 지원한다.

String 타입의 파라미터를 지정하면 설정 화면은 텍스트 필드를 보여줄 것이고, Boolean 타입의 파라미터는 스위치를 보여줄 것이다.

그리고 숫자는 Int 타입의 파라미터는 스테퍼를, Decimal 타입의 파라미터는 슬라이더를 지원한다.

또한 설정 화면은 연락처 선택과 Location 타입의 파라미터를 위한 위치 선택 UI도 제공한다.

설정 화면은 열거형도 지원하는데, 정적 열거형과 동적 열거형을 통해 이를 활용할 수 있다. 여기서 동적 열거형이란 사용자마다 차이가 있을 수 있는 앱의 사용자 데이터를 의미한다. (사용자가 추가한 카드(Card)는 앱을 사용하는 사용자마다 달라질 수 있다.) 동적 옵션은 아래에서 더 자세히 살펴보도록 하자.

설정 화면은 위에서 언급한 것들 말고도 다수의 타입을 각각의 고유한 UI로 지원한다.

  • Date components
  • Duration
  • URL
  • Measurement
  • Currency amount
  • Payment method

또한 파라미터는 다수의 값을 가질 수 있으며, iOS 14에서 Intent는 고정된 크기의 배열을 지원한다. 이를 통해 정의된 갯수만큼의 아이템만 배열에 들어갈 수 있게 지정할 수 있다.

그리고 위젯의 크기에 따라 이 갯수를 별도로 지정해줄 수 있다.

영상의 06:43 ~ 09:39 구간에선 기본적으로 Intent Definition 파일에 Intent와 그의 파라미터를 입력하는 방법을 보여주고 있다.

Dynamic Options

많은 경우, 위에서 언급한 사용자가 등록한 카드와 같이 위젯 설정에서 보여주고 싶은 데이터는 사용자마다 달라질 수 있다. 그리고 이런 유형의 데이터는 Intent Definition 파일에서 지정해줄 수 없다.

대신 Intent Definition 파일에서 Dynamic Options 체크박스를 체크하면 동적 옵션을 활성화할 수 있다.

이렇게 동적 옵션을 활성화하면 위젯 설정 화면에서 원하는 값을 직접 입력할 수 있는 대신 사용자가 선택할 수 있는 값을 검색하기 위해 앱에 요청해야 한다는 사실을 시스템에 알리게 된다. 이렇게 동적 옵션을 활성화하면 두 가지가 발생한다.

첫째, 위젯의 뒷면(설정 화면)의 동적 옵션이 활성화된 파라미터의 UI가 옵션의 목록을 보여주는 모달을 여는 버튼 모영으로 변경된다.

둘째, 여러분의 앱에서 구현해야 할 두 가지 메서드가 생성되는데, 하나는 선택 가능한 옵션을 제공하는 메서드, 나머지 하나는 기본 값을 제공하는 메서드이다.

이 메서드들은 Xcode가 생성해주는 Intent Handler 프로토콜의 일부이다. 여러분은 앱이나 Intent 익스텐션에서 이 프로토콜을 채택하는 클래스를 생성해야 한다. 이 클래스는 사용자가 위젯을 설정할 때 선택 가능한 옵션을 제공하기 위해 시스템에 의해 사용된다.

예제 앱에선 아래와 같이 선택 가능한 카드의 목록과 기본 선택 카드를 위의 메서드 구현을 통해 제공하고 있다.

선택 가능한 옵션을 제공하는 메서드를 사용하면 단순 리스트 형태로 옵션을 제공하거나 그룹화된 리스트 형태로 옵션을 제공할 수 있다.

아래는 그룹화된 리스트 형태로 옵션을 제공한 모습이다.

Search

기본적으로 상단의 검색 바는 여러분이 제공한 옵션을 필터링하는 역할을 한다. 몇몇의 경우 옵션이 리스트에 한번에 보이지 않을 정도로 많을 때 검색을 활용할 수 있다.

이를 위해 Intent handler provides search results as the user types 체크박스를 활성화해야 한다. 또한 Prompt Label(검색에 도움을 줄 수 있는?)을 제공할 수도 있다.

Intent handler provides search results as the user types 체크박스를 활성화하면 선택 가능한 옵션을 제공하는 메서드는 searchTerm 파라미터를 추가로 받게 된다.

처음 사용자가 리스트를 마주하면 이 메서드가 호출될 때 searchTermnil로 전달된다. 그리고 이후에 사용자가 타이핑을 하기 시작하면 이 메서드는 입력된 문자열과 함께 다시 호출될 것이다.

영상의 12:23 ~ 15:11 구간에선 동적 옵션을 구현하는 방법을 직접 시현을 통해 소개하고 있다.

Configuration experience

이번엔 위젯의 설정 화면의 외형을 커스터마이징 해보자.

먼저 설정 화면의 제목과 설명을 configurationDisplayName 변경자와 description 변경자를 이용해 각각 지정해줄 수 있다.

background와 accent 색상 역시 별도로 지정해줄 수 있다.

이를 위해선 먼저 위젯 익스텐션의 에셋 카탈로그에 색상의 이름과 함께 색상을 추가해야 한다.

그리고 이렇게 추가된 색상과 색상의 이름을 위젯 익스텐션의 build settings에서 각각 Global Accent Color NameWidget Background Name에 지정해주면 된다.

그리고 설정 화면에서 특정 파라미터의 값을 기반으로 다른 파라미터를 보여주거나 숨기고 싶을 수 있는 경우가 생길 수 있다.

위의 예제를 통해 두 파라미터의 관계를 살펴보면 Mirror Calendar App을 끄면, Calendar 파라미터가 나타나 어떤 캘린더가 보여야 하는지 수동으로 선택할 수 있어야 한다.

이를 위해 Intent Definition 파일

에서 Calendar 파라미터를 선택 후 Mirror Calendar App 파라미터를 Parent Parameter로 지정해주면 된다.

그리고 Mirror Calendar Appfalse일 때만 Calendar가 보여야 하기 때문에 Show If Parenthas exact value로, ValueFalse로 지정해야 한다.

System intelligence

위젯은 단순히 하나만 배치할 수도 있지만 스택안에서 여러 위젯을 관리할 수 있다. 그리고 이 스택을 통해 적절한 타이밍에 특정 위젯을 최상단으로 올려 사용자에게 매번 적절한 위젯을 보여줄 수 있다. 이렇게 시스템이 적절한 타이밍과 위젯을 고르는데 영향을 미치는 것이 바로 intelligence, 즉 지능이다.

이번 챕터에선 다음의 두 가지 물음에 대해 살펴볼 예정이다.

  • How do stacks behave intelligently?
  • How can i appear at just the right time?

첫 스택 지능을 구현하는 기본 설계 원리를 살펴보고 여러분이 개발한 앱을 이번에 새롭게 등장한 홈 스크린 경험(스택)의 일부에 포함시키기 위해선 어떻게 새 API들을 구현해야 하는지를 살펴볼 것이다.

How do stacks behave intelligently?

어떤 것이 좋은 스마트 스택을 만들수 있을까?

스택은 사용자에게 분명한 가치를 적절한 타이밍에 한눈에 볼 수 있는 정보를 제공해야 한다.

예를 들어 뇌우가 오고 있다는 사실을 앱이 안다면 사용자에게 단순히 온도를 주기적으로 갱신해주는 것보다 뇌우가 오고 있다는 사실을 알려주는 것이 더 직관적이다.

시스템은 위젯을 올릴 때 두 가지 요인에 기반한다.

첫 번째는 사용자 행동 기반(behavior-based)이다. 시스템은 사용자가 특정 시간에 주로 찾는 정보를 제공하는 위젯을 해당 시간에 위로 올린다. 날씨 앱을 자주 들여다보는 사용자에겐 날씨 앱을 위로 올려 사용자가 이를 통해 날씨 정보를 빠르게 찾을 수 있도록 한다.

두 번째는 여러분의 앱이 제공하는 관련성 정보(relevant information)이다. 예를 들어 날씨 앱의 경우 뇌우가 왔을 때 위젯이 이를 시스템에 매우 관련성이 높은 정보가 있음을 알릴 수 있으며, 스택은 이를 통해 위젯을 위로 올릴지를 결정한다.

How can i appear at just the right time?

시스템이 위젯을 올리는데 필요한 정보를 제공하는데 사용할 수 있는 API들에 대해 살펴보도록 하자.

Behavior-based

먼저 행동 기반 요인에 영향을 줄 수 있는 API에 대해 살펴보자.

iOS 12에서 등장한 Shortcuts와 사용자 정의 Intent donations를 통해 여러분의 앱에서 사용자가 무엇을 했는지 시스템에 알릴 수 있게 되었다. 그리고 시스템은 이 정보를 바탕으로 Spotlight에서 그 다음 행동을 예측 및 추천할 수 있다. iOS 14에서 시스템은 동일한 정보를 바탕으로 언제 여러분의 위젯을 위로 올릴 것인지를 결정한다.

예제 앱에서 사용자가 앱에서 특정 카드를 확인했을 때 이 사실을 시스템에 알릴 건데, 이를 Intent donation을 통해 시스템에 알릴 것이다. 내부적으로 이게 어떻게 동작하는지 알아보기 전에 먼저 이를 위한 세팅을 하는 법에 대해 살펴보자.

먼저 기존의 Intent Definition 파일에서 Intent is elligible for Siri Suggestions 항목을 활성화한다.

해당 항목이 활성화되면 아래 Suggestions 필드가 추가된다. 우린 사용자가 앱에서 특정 카드를 확인했을 때 해당 카드의 내역을 보여주도록 구성된 위젯을 상단으로 올려야 하기 때문에 Supported Combinationscard 파라미터를 추가해야 한다.

이렇게 설정을 마쳤고, 앱에서 사용자가 카드를 확인했을 때 이 사실을 시스템에 알릴 수 있도록(donate) 코드를 작성해주어야 한다.

예제 앱에서 사용되는 ViewRecentPurchasesIntent를 생성하고 여기에 현재 사용자가 확인한 Card 인스턴스를 담아 이를 donate하는 코드이다. 위에서 Supported CombinationsCard 파라미터만 추가했기 때문에 Category 파라미터를 추가해도 시스템은 Card만 고려한다.

그럼 시스템이 이를 통해 어떻게 동작하는지를 살펴보도록 하자.

사용자가 식료품 가게에선 정오에 AcmeCard로 주로 결제하고 저녁은 주로 SoupPay로 결제한다고 했을 때 donate된 정보를 바탕으로 시스템은 각각 AcmeCard는 정오에, SoupPay는 저녁에 사용된다는 사실을 알 수 있기 때문에 카테고리에 상관없이 AcmeCard로 구성된 위젯을 정오에 위로 올릴 것이다.

만약에 Supported CombinationsCategory를 추가하고 IntentCategory를 함께 donate한다면 시스템은 사용자가 명확하게 AcmeCard와 Groceries 카테고리로 설정한 위젯만 위로 올릴 것이다. 즉 Supported Combinations은 시스템과 소통하는 방법이다.

위의 과정을 요약하자면 아래와 같다.

Relevant Information

이번에 살펴볼 API는 앱에서 중요하고 관련된 정보가 생겼을 때 시스템이 해당 위젯을 위로 올리는데 사용되는 API다.

먼저 Timeline을 간략하게 살펴보면, WidgetKit을 사용하여 다양한 시점에서 위젯의 모양을 결정하는 Timeline을 제공할 수 있다.

또한 최근 구매 내역과 같이 구매가 발생했을 때 각각에 해당하는 entry를 제공함으로써 위젯이 이를 사용해 실시간으로 새 정보에 반응할 수 있다.

예제 앱에서 사용자가 $50 이상의 구매가 발생하면 알람을 받기 원한다고 가정했을 때, 어떻게하면 시스템에게 예제 앱 위젯이 관련성 높은 정보($50 이상의 구매가 발생)를 갖게 되었다고 알릴 수 있을까?

TimelineEntryRelevance 객체를 TimelineEntry와 함께 제공함으로써 이런 정보를 시스템에 전달할 수 있다.

TimelineEntry는 세 가지 요소로 이루어져 있다. Date는 이 entry가 언제 랜더링되어야 하는지를 나타내고, View는 랜더링되어야 할 뷰를 의미한다. 그리고 이 entry의 연관성을 나타내는 RelevanceTimelineEntryRelevance 객체로 scoreduration 프로퍼티를 갖는다.

score를 먼저 살펴보자면, score는 과거에 제공된 모든 entry들과 비교했을 때 이 entry가 얼마나 연관되어 있는지를 나타낸다. 시스템은 다른 entry들과 관련하여 score만을 고려하기 때문에 범위와 스케일은 정의하기에 달려 있다. 예외적으로 0과 그 이하의 score는 시스템에게 현재 위젯이 관련 정보를 갖고 있지 않고, 위로 올라오지 말아야 한다고 알리는데 사용된다.

예제 앱으로 돌아와서 우리가 의도한대로 동작하기 위해 $50 이상의 구매가 발생했을 때는 1, 최근 구매 내역이 없으면 0 그리고 이외의 구매 내역은 0.1로 score로 지정해보자. 이렇게 score를 지정하면 사소한 구매 내역에 대해선 위젯이 올라올 수 있는 기회가 적지만 대신 굵직한 구매 내역은 확실한 우선순위를 갖을 수 있다.

다른 위젯들이 제공하는 score는 상관없음을 기억하자. score는 오로지 여러분이 제공한 score랑만 비교된다.

이번엔 결제 금액을 score로 사용해보자. 이를 통해 특정 금액을 넘는지 아닌지가 아닌 결제 금액에 따른 우선순위가 정해진다.

score를 살펴보았고 이제 duration을 살펴보자.

duration은 잘 정의된 일정 시간동안 관련성 점수(score)를 고정할 때 사용된다. 그렇지 않으면 duration을 0으로 두면 된다. 이는 관련성 점수가 다음 TimelineEntryRelevance가 수신될 때 까지만 지속된다는 것을 의미한다.

다은 duration을 활용한 예제이다.

농구 게임의 진행 상태를 알려주는 위젯으로 게임 시작 전에는 score를 0으로 지정하고 게임이 시작하면 score를 1로 지정하고 이를 게임이 진행되는 동안 고정하기 위해 duration을 3시간으로 지정하였다.

그리고 게임이 진행되는동안 관련성 점수에 영향을 미치지 않도록 TimelineEntryRelevancenil로 두고 TimelineEntry를 갱신할 수 있다.

스택의 지능(intelligence)에 정리해보자.

우린 스마트 스택을 이용해 특정 위젯을 스택의 최상단으로 올릴 수 있는 기회를 갖는다. 이를 가능하게 하는 방법은 다음 두 가지다.

  • Donate INIntents that match your configuration intent (User behavior-based)
  • Provide TimelineEntryRelevance for important information (Relevance information)

Stacks, Grids, and Outlines in SwiftUI

링크

SwiftUI은 기본 레이아웃 요소들은 Compositional 하게 사용 되도록 설계되었다. 이들 각각을 사용, 조합하여 원하는 레이아웃을 구성할 수 있다.

Stacks

우린 VStackHStack의 조합으로 아래와 같은 갤러리 레이아웃을 구성할 수 있다. 그리고 이들 Stack만으로는 스크롤이 불가능하기 때문에 ScrollView 안에 이들을 구성하였다.

하지만 갤러리 이미지가 많아질수록 그 모든 이미지를 한 번에 불러오는 데 있어 문제가 발생한다. 이는 반응성을 저해하는 요소가 될 수 있다.

이를 위해 Lazy 하게 컨텐츠를 불러올 수 있는, 즉 처음 랜더링될 때 필요한 컨텐츠만 불러오고 이후 필요할 때 나머지를 컨텐츠를 불러올 수 있는 LazyVStackLazyHStack이 새로 생겼다.

이들을 통해 메모리 공간이 불필요하게 커지는 것을 방지할 수 있다. 사용법은 매우 간단하다.

위의 갤러리 레이아웃에서 별점을 위한 HStack도 존재하는데, 이들 역시 Lazy 하게 구성해야 할까?

정답은 “아니오”다. 이들은 화면에 보여졌을 때 모든 컨텐츠를 한 번에 볼 수 있다. 그렇기 때문에 LazyHStack을 사용해서 그 어떤 이득도 볼 수 없다. 만일 일반 Stack과 Lazy Stack 중 어떤 것을 사용해야 할지 고민된다면 일반 Stack을 사용하는 걸 권장한다.

Instruments를 사용해 프로파일링을 한 후 성능의 병목 현상을 발견했고 이를 해결할 때만 Lazy Stack을 사용하도록 하자

Grids

그리드 레이아웃을 위한 새로운 기본 레이아웃이 추가되었는데 LazyVGridLazyHGride가 그것이다. 이들의 사용법 역시 그리 어렵진 않다.

LazyVGrid 기준으로 원하는 행의 구성 정보와 함께 기존의 VStack 코드를 대체하였다. 그리고 이런 행의 정보를 나타내는 GridItem 역시 새로 추가되었다.

GridItem을 사용해 그리드 레이아웃의 각각의 아이템의 크기와 위치를 지정해 줄 수 있다.

이들은 기본적으로 유연하기(flexible) 때문에 위와 같이 세 개의 GridItem을 사용하면 각 행의 너비는 모두 동일한 너비를 갖게 된다.

GridItem은 단순히 갯수만이 아닌 최소 너비 값과 같은 특정 사이즈 크기를 이용해 원하는 그리드 레이아웃을 구성하는데 사용할 수 있다.

Lists

List 역시 기본 레이아웃 구성 요소 중 하나로 스크롤과 선택된 항목(selection) 관리와 같은 인터렉션을 지원하는 레이아웃 요소이다. List는 항상 컨텐츠를 Lazy 하게 불러온다.

이뿐만 아니라 이번에 추가된 기능으로 우린 리스트를 통해 컨텐츠를 그룹화하여 표현할 수 있게 되었다.

children keypath를 사용하는 생성자를 통해 우린 List의 컨텐츠를 쉽게 그룹화할 수 있게 되었다. 내부적으로 어떻게 이것이 가능한지 살펴보도록 하자.

Outlines

OutlineGroupForEach와 동일하게 데이터를 순회하지만 단일 계층의 flat 한 콜렉션 타입의 데이터를 순회하는 ForEach와 달리 OutlineGroup은 트리 구조 형태의 데이터 타입을 순회한다.

OutlineGroupSection과 함께 사용하면 .listStyle(_:)에 따라 다양한 기본 스타일의 헤더 뷰를 사용할 수 있다.

이렇게 계층화된 데이터를 사용해 동일한 외형의 Row를 사용하는 것이 아니라 서로 다른 데이터 타입이지만 단순히 이들을 계층화하고, 보여주기와 숨기기 기능을 사용하고 싶다면 이번에 새로 추가된 DisclosureGroup을 사용하면 된다.

OutlineGroup이 내부적으로 어떻게 동작하는지 살펴보자.

  1. 예제의 OutlineGroupgraphics 모델을 사용한다.
  2. OutlineGroup은 동일한 모델을 사용하는 ForEach로 확장된다.
  3. ForEachbodyDisclosureGroup으로 graphics의 아이템 하나를 사용한다.
  4. DisclosureGroup은 다시 하나의 graphic를 데이터로 갖는 OutlineGroupbody로 갖는다.

이런 1~4의 과정은 children이 없는 graphic을 찾을 때까지 반복된다. 이러한 계산 과정은 그룹이 확장되었을 때(사용자가 그룹을 확장시켰을 때)에만 진행되기 때문에 최소한의 과정만을 갖게 된다.

Meet WidgetKit

링크

What makes a greate Widget?

Glanceable

위젯은 미니앱이 아니다. 단순히 앱의 컨텐츠를 보여주는 것이므로, 컨텐츠에 집중해야하며 한 눈에 볼 수 있는(Glanceable) 컨텐츠를 제공해야 한다.

Relevant

스마트 스택을 이용해 특정 시점에 적절한 위젯을 보여주어야 한다. 즉 연관성이 있어야 한다.

연관성을 뒷받침 하는 요소들은 다음과 같다.

  • Stacks use on-device intelligence
  • Siri Shortcuts donation
  • WidgetKit API

관련 WWDC 세션 - Add Configuration and intelligence to Your Widgets

Personalized

위젯에는 세 가지 크기가 존재한다. 모든 크기를 지원할 필요는 없지만 최대한 많은 사이즈를 지원해 위젯의 개인화를 더욱 향상 시킬 수 있다.

또한 Shortcut에서 사용했던 Intent를 이용해 구성 옵션(Configuration Option)과 구성 화면(Configuration UI)를 쉽게 구현할 수 있다.

How WidgetKit works

위젯은 멀티플랫폼을 지원해야 하기 때문에 SwiftUI로 만들어졌다.

위젯은 위에서 언급했듯이 한 눈에 볼 수 있어야(Glanceable) 한다. 이를 위해 WidgetKit은 타임라인에 따른 연속된 뷰 계층을 반환해야 한다. 그렇기 때문에 이는 백그라운드 익스텐션에 속한다.

타임라인에 따른 연속된 뷰 계층을 패키징하여 홈 스크린에 전달하면 홈 스크린은 주어진 타임라인에 따라 정해진 뷰를 그리게 된다. 우리는 이런 메커니즘을 통해 런치 프로세스, 뷰 로딩 등을 피할 수 있다. 이를 통해 위젯은 항상 적절한 컨텐츠를 즉시 볼 수 있게끔 준비되어 있다.

이렇게 미리 뷰가 준비되어 있다는 것은 다른 곳에서 이를 재사용할 수 있다는 것을 의미한다. 아래는 이렇게 준비된 뷰가 위젯 갤러리에서 사용되는 모습이다.

이런 타임라인 메커니즘으로 우리는 위젯을 통해 항상 적절한 컨텐츠를 바로 볼 수 있다.

그리고 이런 타임라인은 메인 앱에서 사용자가 컨텐츠에 영향을 주는 데이터를 변경했을 때 갱신될 수 있다. 혹은 익스텐션에서 이러한 갱신을 스케쥴링해줄 수도 있다.

예를 들어 캘린더 위젯은 하루동안의 이벤트가 언제 일어날지에 대해 알고 있다. 익스텐션은 이 정보를 바탕으로 적절한 시간에 해당하는 뷰를 랜더링한다.

How to make a great Widget?

훌륭한 위젯을 만드는 방법을 아래 주제들을 통해 알아보자

  • Defining a widget
  • Creating a glanceable experience
  • Views, timelines and reloads
  • Personaliztion and intelligence

Defining a widget

위젯을 정의하기 위해선 몇 가지 컨셉에 대해서 짚고 넢어가야한다.

  • kind
  • configuration
  • supportFamilies
  • placeholder

처음 위젯을 설계할 때 하나의 익스텐션으로 다양한 유형의 위젯을 지원할 수 있는 메커니즘을 구상했다.

주식 앱 익스텐션을 예로 들자면, 이는 몇 가지 종목에 대한 개요를 볼 수 있는 위젯을 제공한다. 하지만 추가로 한 가지 종목에 대한 상세 정보를 확인할 수 있는 위젯을 제공하거나 혹은 macOS의 알람 센터에서 확인할 수 있는 위젯을 제공한다.

위젯의 종류(kind)는 자신들이 어떤 유형의 Configuration을 지원하는지를 나타내기도 한다. 이런 Configuration에는 두 가지가 존재한다.

  • StaticConfiguration
  • IntentConfiguration

StaticConfiguration

피트니스앱의 위젯은 단순히 현재 피트니스 상태를 알려주고, 딱히 사용자가 이를 구성할 수 있도록 지원할 필요는 없다. 이는 StaticConfiguration 타입이다.

IntentConfiguration

다시 알림앱은 목록을 사용자가 수정하고 개인화할 수 있다. 이는 IntentConfiguration 타입에 해당한다.

위젯은 하나 혹은 다수의 supportedFamilies를 지원할 수 있다. 기본적으로 위젯은 모든 supportedFamilies 타입을 지원한다.

placeholder는 위젯의 기본 컨텐츠(Default Content)가 된다. placeholder를 통해 위젯이 어떤 유형의 컨텐츠를 제공하는지만을 나타내야지 사용자 데이터가 포함되어 있어서는 안된다.

또한 placeholder는 자주 볼 수 있는 UI가 아니고 언제 보일지는 보장할 수 없다. 일반적으로 기기 환경 설정이 변경되었을 때 새 placeholder를 요청하곤 한다.

사용자에게 위젯이 어떤 유형의 컨텐츠를 제공하는지를 잘 나타내는 placeholder가 훌륭한 placeholder다.

아래의 코드는 위에서 살펴본 네 가지 키워드가 모두 담겨져있다.

Creating a glanceable experience

아래는 glanceable한 위젯의 예들이다. 위젯은 유용한 정보를 제공하며 사용자로 하여금 위젯을 탭 하여 더 많은 정보를 볼 수 있게끔 유도하고 있다.

한 눈에 보기 쉬운 위젯을 만들기 위한 요소 중 하나는 Stateless한 UI다. 이는 다음과 같은 특성을 갖는다.

  • No Scrolling
  • No videos or animated images
  • Tap interactions

위젯은 딥 링크를 지원하므로, 탭을 통해 메인 앱의 특정 컨텐츠로 사용자를 유도할 수 있다. 이런 딥 링크는 widgetURL를 이용해 구현할 수 있다.

Views, timelines and reloads

뷰, 타임라인 그리고 갱신은 위젯의 엔진 역할을 한다.

뷰를 위한 세 가지 개념이 존재한다.

  • Placeholder
  • Snapshot
  • Timeline

Snapshot

Snapshot은 시스템이 위젯을 빠르게 제공하기 위해 필요한 단일 진입점이다. 이를 위해 익스텐션은 이 뷰를 최대한 빨리 반환해주어야 한다. 그리고 이렇게 반환된 Snapshot은 위젯 갤러리에서 확인할 수 있다.

보통 Timeline의 첫 번째 진입점과 Snapshot은 동일한 진입점으로 반환될 수 있다. 그렇기 때문에 위젯 갤러리에서 보는 것은 사용자가 위젯을 디바이스에 추가했을 때의 보는 모습과 동일하다.

Snapshot이 단지 하나의 단일 진입점이라면, 제시간에 보여지는 다수의 연속된 뷰는 Timeline이라고 할 수 있다.

Timeline

Timeline은 뷰와 날짜의 조합으로 어떤 뷰가 언제 보여져야 하는지를 나타낸다. Timeline은 다크모드, 라이트모드 모두 반환해야 한다.

익스텐션이 진입점을 제공하면 우리는 이 정보를 받아 디스크에 뷰 계층을 직렬화한다. 이를 통해 적절한 타이밍에 각 항목을 렌더링할 수 있다. 이런 방식으로 시스템은 수많은 Timeline을 통해 동시에 수많은 위젯에 이를 적용할 수 있다.

타임라인은 일반적으로 하루치 컨텐츠를 제공해야 한다. 그러나 주어진 시간에 따른 컨텐츠가 아닌 최신 정보를 보여주어야 할 때가 있다. 우린 이를 갱신(Reload)이라 부른다.

Reload

Reload란, 시스템이 익스텐션을 깨우고 각각의 위젯을 위한 새 Timeline을 요청하는 것을 말한다. Reload를 통해 사용자의 컨텐츠가 항상 최신 상태로 유지되도록 할 수 있다.

아래는 TimelineProvider 프로토콜로 WidgetKit에 언제 위젯을 갱신해주어여 하는지 알릴 때 사용된다.

reloadPolicy

시스템에게 언제 다음 Timeline을 요청해야 하는지를 알려주는 일종의 갱신 정책이다.

시스템은 reloadPolicy를 받아 위젯을 갱신(reload)한다. 자주 보는 위젯은 더 자주 갱신될 것이고, 아닌 위젯은 덜 자주 갱실될 것이다. 또한 기기 환경 설정이 변경되면 시스템은 강제로 위젯을 갱신한다.

이렇게 시스템에 의한 위젯 갱신도 있지만 메인 앱 주도의 갱신도 존재한다.

백그라운드 노티피케이션 혹은 앱 내의 데이터 변경에 의해 위젯이 갱신될 수 있는데 이때 우리는 WidgetCenter를 사용해 위젯을 갱신해줄 수 있다.

서버로부터 받아온 정보를 바탕으로 위젯을 갱신해주기 위해선 백그라운드 세션을 사용해야 한다. 또한 서버 통신으로 만들어진 payload는 onBackgroundURLSesionEvents 변경자를 통해 전달된다. 요청은 일괄처리하고, 서버 통신은 필요한만큼만 사용해야 한다.

위젯은 매초마다 수행되는 작업이 아니다. 실시간 실행 환경도 아니다. 상태에 맞는 갱신 정책을 통해 위젯을 효율적으로 갱신해야 한다.

Personalized and intelligence

위젯의 개인화와 지능은 두 가지 요소로 결정된다.

  • Intents
  • Relevance

Intents

Intent는 사용자가 위젯을 구성하는데 사용되는 메커니즘이다.

Intent를 통해 사용자에게 일종의 질문을 하고 (어떤 위치의 날씨 정보를 원하는지, 어떤 주식 종목을 원하는지) 시스템이나 앱은 이에 대한 응답으로 위젯을 갱신한다. 이를 통해 우린 위젯의 사용자화를 향상시킬 수 있다.

Relevance

스마트 스택의 지능에 영향을 미칠 수 있는 요인 중 하나다.

When users perform actions in your app, your app can donate shortcuts.
If your widget is backed by the same INIntent, then your widget may be rotated to in the stack when the user would have typically perform that action.

또한 TimelineEntryRelevance 구조체의 scoreduration을 이용해 관련성(Relevance)에 영향을 줄 수 있다.

[관련 WWDC 세션 - Add Configuration and intelligence to Your Widgets](

Embrace Swift type inference

WWDC 2020

스위프트는 코드의 안전성을 훼손하지 않고 간결한 코드를 작성하기 위해 타입 추론(Type inference)을 광범위하게 사용한다.

이 영상을 통해 우리는 다음의 것들을 살펴볼 것이다.

  1. 타입 추론을 활용하는 법
  2. 컴파일러에서 타입 추론이 어떻게 동작하는지
  3. 타입 추론에 의해 발생할 수 있는 에러의 원인과 이를 해결하는 방법

What is type inference?

먼저 타입 추론이 무엇인지 간단하게 알아보자.

타입 추론은 프로퍼티에 타입을 명시적으로 선언하지 않아도 컴파일러가 문맥에 따라 타입을 추론하는 것을 말한다.

img

위의 코드에서 String을 명시적으로 선언하지 않아도 컴파일러는 이를 String으로 추론한다. 좀 더 복잡한 예제 코드로 타입 추론을 활용하고, 컴파일러에서 타입 추론이 어떻게 동작하는지를 살펴보자.

img

위의 코드에서 FilteredList는 주어진 데이터를 리스트 형태로 보여주고 필터링 기능을 제공하는 재사용 가능한 뷰다. 이 FilteredList는 재사용 가능해야 하므로 기본적으로 생성자 인자들은 제네릭 해야 한다. 위의 코드에서 FilteredList를 사용하면서 따로 타입을 명시해 주지 않고 있다. 이는 컴파일러가 타입 추론을 하기 때문에 가능한 일인데, 이를 좀 더 알아보기 위해 FilteredList가 어떻게 정의되어 있는지 코드로 살펴보도록 하자

img

Element, FilterKey 그리고 RowContentFileteredList가 생성될 때 실제 타입, 즉 Concrete 타입으로 대체된다. 이제 선언부와 호출부를 나란히 두고 비교해보자.

img

제네릭 타입으로 인해 복잡한 선언부와 비교했을 때 호출부는 훨씬 깔끔한 코드임을 확인할 수 있다. 이는 컴파일러가 주어진 값들로 타입 추론을 하기 때문에 가능한 일인데, 타입 추론이 아닌 명시적으로 타입을 명시한다면 아래와 보다 복잡한 코드를 작성해야 한다.

글

그렇다면 컴파일러는 어떻게 타입 추론을 하는 것일까? 타입 추론은 일종의 퍼즐이라고 할 수 있다. 우린 퍼즐을 하면서 하나의 조각이 맞춰질 때 다음 조각을 자연스럽게 유추할 수 있다. 하나의 퍼즐이 맞춰질 때마다 다음 조각에 대한 단서를 우린 유추할 수 있다. 컴파일러는 이렇게 퍼즐을 풀 듯이 우리의 코드에서 단서를 찾아 퍼즐을 하나씩 맞춰가며 타입을 추론한다.

위의 코드를 사용해서 컴파일러가 어떻게 퍼즐을 맞춰가는지 살펴보자.

img

먼저 첫 번째로 인자로 넘기는 smoothies라는 단서를 통해 Element의 타입을 추론할 수 있다. smoothies[Smoothie] 타입으로 ElementSmoothie 타입으로 대체된다.

img

우린 Element라는 퍼즐 조각을 맞췄기 때문에 이를 통해 또 다른 단서를 얻을 수 있다. 바로 FilterKey다. \.title\Smoothe.title로 대체되고 Smoothietitle 프로퍼티는 String이란 것을 알 수 있기 때문에 FilterKeyString으로 대체된다.

img

img

RowContent 역시 ViewBuilder 클로저 안에서 SmoothieRowView가 반환되기 때문에 SmoothieRowView로 대체될 수 있다.

img

img

이런 식으로 컴파일러는 이전 단계의 단서를 통해 하나씩 타입을 추론해 나간다. 하지만 이렇게 얻은 이전 단계의 퍼즐 조각(단서)이 맞지 않는다면 소스 코드에 에러가 발생했다는 것을 의미한다. 즉 맞지 않은 타입이 들어갔기 때문에 컴파일러는 더 이상 타입 추론을 진행할 수 없다.

Smoothie.title이 아닌 Bool 타입의 Smoothie.isPopular로 바꿔보자

img

그렇다면 컴파일러는 Bool 타입을 FilterKey의 조각으로 사용할 것이다. 하지만 Bool 타입은 hasSubString(_:) 메서드가 없기 때문에 이후의 타입 추론을 진행할 수 없고, 에러를 뱉는다.

img

이렇게 스위프트 컴파일러는 추후에 에러 메시지를 출력할 때 사용하기 위해 에러 추적 기능을 타입 추론에 통합시켰다. 컴파일러는 타입 추론을 진행하면서 직면한 에러를 기록한다. 그리고 컴파일러는 에러를 고치고 타입 추론을 계속 진행하기 위해 휴리스틱을 사용한다.

그리고 타입 추론이 끝나면 컴파일러는 타입 추론을 진행하면서 수집한 에러를 actionable한 에러 메시지(자동으로 코드를 수정할 수 있는)나 에러를 발생시킨 실제 타입에 대한 메시지와 함께 개발자에게 알린다.

img

이렇게 통합된 에러 추적 시스템은 Xcode11.4의 스위프트 5.2에선 많은 오류 메시지에 도입되었고, Xcode12의 스위프트 5.3에선 모든 에러 메시지에 적용되었다. Embrace Swift type inference

GeometryReader in SwiftUI

SwiftUI를 공부하다 보면 자연스레 등장하는 개념이 바로 GeometryReader다. 어렴풋이 이해하고 있던 개념을 포스팅을 통해 정리해보려 한다.

What is GeometryReader

GeometryReader란 무엇인가?

SwiftUI에선 UIKit으로 레이아웃을 작성할 때와 달리 뷰 객체에 직접 접근할 수 없다. 뷰 객체에 직접 접근해 뷰 정보(size, position 등)에 알 수 있었던 것과 달리 SwiftUI에서 뷰 객체는 일시적인(transient) 객체로 프레임워크가 뷰를 그리고 난 후 객체는 사라지기 때문에 UIKit과 같은 방식으론 뷰에 관한 정보를 알 수 없다.

그렇다고 모든 뷰에 .frame(width:, height) 변경자를 사용해 직접 고정 값을 넣을 수도 없는 일이다. 이를 위해 등장한 개념이 GeometryReader로 상위 뷰의 Geometry 정보를 하위 뷰에 제공하는 역할을 한다.

보다 정확히는 상위 뷰가 제안한 Geometry 정보

1
2
3
4
5
6
7
struct SomeView: View { 
var body: some View {
GeometryReader { proxy in
// proxy를 통해 뷰 정보에 접근.
}
}
}

GeometryReader에 대해 더 자세히 살펴보기 전에 아직은 완성되지 않은 듯한 애플 공식 문서가 설명하고 있는 GeometryReader를 살펴보자.

A container view that defines its content as a function of its own size and coordinate space.

컨텐츠를 자신의 크기 및 좌표 공간의 함수로 정의하는 컨테이너 뷰

이 설명을 통해 자세히는 아니지만 몇 가지 키워드로 힌트는 얻을 수 있다.

  • 컨테이너 뷰
  • 자신의 크기 및 좌표 공간

Layout Process

GeometryReader를 이해하고 올바르게 사용하려면, SwiftUI에서 레이아웃을 그리는 세 단계에 대해 반드시 이해해야 할 필요가 있다.

이전 글을 참고하셔도 좋습니다.

이 세 단계를 간단히 요약하자면 아래와 같다.

  1. 부모 뷰가 자식 뷰의 사이즈를 제안한다.
  2. 자식 뷰는 제안받은 사이즈를 이용해 자신의 사이즈를 결정한다. ((언제나 제안받은 사이즈 == 자신의 사이즈) == false)
  3. 부모 뷰는 결정된 자식 뷰를 적절히 위치시킨다.

GeometryReader는 상위 뷰에서 제안한 사이즈를 자신의 사이즈로 사용 및 반환한다. 가능한한 최대한 확장한다고 생각하면 된다.

GeometryProxy

GeometryReader는 컨테이너 뷰의 한 종류이며, 다른 컨테이너 뷰의 ViewBuilder와는 다르게 인자를 하나 받는데 그것이 바로 GeometryProxy 객체다. 실제로 상위 뷰의 정보는 이 객체를 통해 접근이 가능하다. 기본적으로 위에서 살펴보았듯이 GeometryProxysize 프로퍼티를 갖고 있다.

이외에도 .frame(in: )라는 메서드가 있으며, 서브스크립트도 지원한다.

.frame(in:)

GeometryReader를 통해 size뿐만 아니라 .frame 메서드를 통해 CGRect에 접근해, 좌표값도 알 수 있다.

이 좌표값은 지정한 좌표평면 공간에 따라 달라질 수 있다.

  • .local - 자신이 속한 컨테이너 뷰 안에서의 좌표를 반환한다.
  • .global - 전체 스크린에서의 좌표를 반환한다.
  • .named(_:) - 지정한 좌표평면에서의 좌표를 반환한다.
    • .coordinateSpace(name: 을 통해 사용자 정의 좌표평면을 지정할 수 있다.

.frame(in:) 메서드를 통해 UIKit에서 convert 메서드를 사용해 좌표를 변환하던 작업이 보다 수월해졌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct ContentView: View {
var body: some View {
GeometryReader { proxy in
HStack(spacing: 0.0) {
...
ZStack(alignment: .topLeading) {
...
GeometryReader { innerProxy in
Rectangle()
.foregroundColor(Color.pink)
.onTapGesture {
let local = innerProxy.frame(in: .local)
let global = innerProxy.frame(in: .global)
let named = innerProxy.frame(in: .named("OuterGeometry"))

print("[local] minX : \(local.origin.x), minY : \(local.origin.y)")
print("[global] minX : \(global.origin.x), minY : \(global.origin.y)")
print("[named] minX : \(named.origin.x), minY : \(named.origin.y)")
}
}.frame(width: 50, height: 50)

}
...
}
}.coordinateSpace(name: "OuterGeometry")
}
}
1
2
3
4
[RESULT]
[local] minX : 0.0, minY : 0.0
[global] minX : 112.5, minY : 20.0
[named] minX : 112.5, minY : 0.0

subscript(Anchor) -> T

GeometryProxy의 서브스크립트를 이해하기 위해선 Anchor와 함께 Preferences에 대한 이해가 있어야 한다. 그러므로 이는 추후 Preferences에 대한 포스팅에서 함께 다뤄볼 예정이다.

PreferencesGeometryReader와 함께 SwiftUI에서 중요한 개념 중 하나로, 하위 뷰에서 상위 뷰로 정보를 전달하는데 사용된다.

Usage

그럼 우린 GeometryReader를 어떻게 사용할 수 있을까? 위에서 언급했듯이 기본적으로 상위 뷰와 비례해 하위 뷰의 사이즈를 조정할 때 사용할 수 있다.

추가로 위에서 언급했던 GeometryReader는 상위 뷰에서 제안한 사이즈를 자신의 사이즈로 사용 및 반환한다.” 특성을 이용하여 Preferences와 함께 사용될 때 하위 뷰의 geometry 정보를 상위 뷰에 전달할 때도 유용하게 사용할 수 있다.

위의 결과 화면은 VStack 내부의 Text들 중 가장 너비가 큰 값을 이용해 Text 각각의 backgroundCircle을 위치시킨 결과다. 그럼 Circle의 적절한 너비와 높이 값이 정해져야 하는데 이 정보는 어떻게 알 수 있으며 어떻게 지정해야 할까?

Preferences에 관한 포스팅에서도 더 자세히 살펴볼 코드지만 그중 일부만 살펴보자.

그전에 이 글을 참고하면 조금이나마 코드를 이해하는데 도움이 될 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
Text("Reset")
.padding()
.fixedSize()
.background(GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self,
value: [proxy.size])
})
.frame(width: size?.width, height: size?.width)
.background(
Circle()
.fill(Color.blue)
)

background안에 투명한 Color 뷰를 넣어 해당 Textsize 값을 알 수 있다.

그 이유를 이해하기 위해선 역시 Layout Process에 대한 이해가 필요한데, 기본적으로 background는 caller의 사이즈를 그대로 인자로 들어가는 뷰에 제안한다. 그리고 GeometryReader는 제안받은 사이즈를 자신의 사이즈로 반환하기 때문에 위의 코드에서 proxy의 사이즈가 곧 Textsize가 된다.

이렇게 알아낸 Text의 사이즈를 상위 뷰로 전달하고 상위 뷰는 이렇게 취합된 Text들의 사이즈 중 가장 큰 사이즈로 Text들의 너비를 조정해 파란색 Circle에 올바른 사이즈를 제안할 수 있게 된다.

Bugs🐛


사실 버그라고 해야 할지, 단순히 버전 차이에 따른 차이인지 정확하게는 알 수 없다. 하지만 iOS 13.x 버전과 iOS 14 버전에서의 GeometryReader의 동작 방식에는 차이가 있다.

바로 GeometryReader 하위 뷰들의 정렬 차이인데, 아래의 이미지를 통해 그 차이를 살펴보자.

코드의 구조는 대략 아래와 같다.

1
2
3
4
5
6
VStack { 
GeometryReader {
Text("…")
}
Text("…")
}

13.x 버전에선 GeometryReaderText를 기본적으로 가운데 위치시키지만, iOS 14 버전에선 Text를 왼쪽 상단 모서리에 위치시킨다. 구글링을 해보았지만, 딱히 명확한 이유를 알 수 없었다..😇

이에 대해 알고 있는 부분이 있으시다면 댓글 부탁드립니다

.

.

.

[는 하루만에 이유를 찾았다.] 😅

그 이유는 Xcode 12 beta 3 release note에서 찾을 수 있었다.

Resolved in Xcode 12 beta

  • Rebuilding against the iOS 14, macOS 11, watchOS 7, and tvOS 14 SDKs changes uses of GeometryReader to reliably top-leading align the views inside the GeometryReader. This was the previous behavior, except when it wasn’t possible to detect a single static view inside the GeometryReader. (59722992) (FB7597816)

top-leading 정렬이 정상 동작이며, GeometryReader가 단일 정적 뷰를 내부 뷰로 가져갈 경우 이를 감지 못하던 이슈(가운데 정렬시키던)가 해결되었다고 언급하고 있다.


참고 자료

App essentials in SwiftUI

WWDC 2020을 통해 SwiftUI에선 AppScene 개념이 추가됐다. 이를 소개하고 있는 App essentials in SwiftUI 세션을 보고 간단히 정리해보았다.

새롭게 등장한 App, Scene 개념으로 UIKit 없이 순수 SwiftUI로만 앱을 만들 수 있게 됐다.

Views, scenes and apps

화면에 보이는 모든 뷰가 하나의 앱에 속하는 것이 아니기 때문에, 하나의 앱이 전체 화면에 대해 완벽히 제어할 수 없다. 나누어진 영역에서 앱이 보여지는 방법은 플랫폼이 제어한다. SwiftUI에선 이렇게 화면 안에 구분된 영역을 Scene이라 부른다.

윈도우는 화면에 보여지는 Scene의 컨텐츠를 보여주는 가장 흔한 방법이다. iPadOS와 같은 플랫폼은 다수의 윈도우를 나란히 보여줄 수 있다. iOS나 watchOS 그리고 tvOS는 각각의 앱에 대해서 하나의 꽉 찬 단일 윈도우를 선호한다. macOS는 Scene의 컨텐츠가 얼마나 다양한 방법으로 보여질 수 있는가를 나타내는 좋은 예다.

macOS에선 아래와 같이 다수의 윈도우로 개별 Scene을 보여주거나 탭으로 여러 Scene을 묶어 보여줄 수 있다.


이렇게 다수의 SceneApp을 구성하고 App, Scene 그리고 View는 하나의 계층 구조를 이룬다.

아래의 앱과 코드를 살펴보자.

ReadingListViewerViewScene의 한 종류인 WindowGroup에 속한다. 그리고 WindowGroupApp 프로토콜을 따르는 BookClubAppScene으로 사용된다. 코드에서 확인할 수 있는 계층구조와 우리가 위에서 살펴본 계층구조가 일치하는 것을 확인할 수 있다.

그리고 BookClubAppReadingListViewer가 선언된 코드 구조도 유사한 것을 확인할 수 있다.

  • AppView, 둘 모두 Data Dependency를 선언할 수 있다.
    • BoolClubApp - @StateObject
      • @StateObject는 이번에 새로 등장한 개념으로 이는 추후에 살펴보도록 하자
    • ReadingListViewer - @ObservedObject
  • AppView, 둘 모두 body 프로퍼티를 통해 사용자 인터페이스를 표시한다.
    • BookClubApp - var body: some Scene
    • ReadingListViewer - var body: some View

세션의 주제와 별개로 Swift 5.3부터 등장한 @main이 선언되어 있는 것을 확인할 수 있다. 이는 프로그램의 시작점을 의미한다. 기본적으로 스위프트 프로그램은 main.swift를 필요로 하는데 @main을 통해 App 프로토콜을 따르고 있는 구조체에 해당 책임을 위임할 수 있다.

Understanding Scenes

WindowGroup

WindowGroup을 통해 다수의 윈도우를 독립적으로 관리할 수 있다.

그리고 이렇게 독립된 윈도우는 서로 독립된 상태를 갖는데 이것이 SwiftUI에서 Scene의 가장 중요한 특징이라고 할 수 있다.

각각의 독립된 윈도우는 서로의 상태에 영향을 주지 않는다. App은 각 Scene이 사용할 수 있는 Shared Model을 제공할 수 있지만, 각 Scene의 뷰들의 상태는 서로 독립적이다.

그리고 위와 같이 앱 스위처에서 보여지는 타이틀을 뷰 변경자를 통해 윈도우별로 다르게 지정할 수 있다. 이는 부모 Scene의 상태에 영향을 줄 수 있는 변경자 중 하나이다.

macOS에선 WindowGroup을 사용해 아래의 기능들을 제공할 수 있다.

  • 다중 윈도우
  • 파일 메뉴에 새 윈도우 생성 메뉴 아이템 추가
    • 단축키 지원 (Command + N)
  • 윈도우 메뉴
    • 개별 윈도우를 위한 메뉴 아이템(윈도우 타이틀)
    • 다수의 윈도우를 하나의 탭 인터페이스로 통합하는 기능을 지원하는 메뉴 아이템

이 모든 것들은 부가적인 코드 없이 SwiftUI가 자동으로 지원하는 기능들이다.

Scene의 생명주기는 실행되고 있는 플랫폼에 의해 관리된다. macOS에선 새 윈도우가 필요하면 WindowGroup은 새 자식 Scene을 생성한다. 이처럼 macOS나 iPadOS와 같이 다중 윈도우를 지원하는 플랫폼에선 WindowGroup은 다수의 자식 Scene을 생성할 수 있다.

각각의 윈도우는 사용자 인터페이스 정의를 공유하지만 모두 독립된 상태를 갖는다. 그렇기 때문에 하나의 윈도우에서의 변화는 다른 윈도우에 영향을 주지 않는다.

플랫폼이 Scene 생명주기 관리에 책임이 있기 때문에, 각 뷰의 상태를 관리할 수 있는 새 프로퍼티 래퍼인 @SceneStorage라는 개념이 새로 등장했다.

이는 고유 키 값을 이용해 저장될 상태를 식별한다. 그리고 상태는 SwiftUI에 의해 적절한 타이밍에 저장되고 복원된다.

Customizing Apps

Document based App

지금까지 살펴본 BookClubApp은 Data-Driven 앱으로 Shared Model을 기반으로 하는 형태의 앱이었다.

이런 형태의 앱뿐만 아니라, 문서 기반의 앱도 존재한다. 이런 형태의 앱에선 DocumentGroup을 사용할 수 있다.

DocumentGroup은 열기, 편집, 저장과 같이 문서 기반의 앱을 관리하는데 필요한 기능을 제공하는 Scene의 한 종류다.

Preferences Window

설정 윈도우(Preferences Window)는 macOS 앱들이 제공하는 공통적인 기능 중 하나이다.

이를 위해 macOS에는 새로운 Scene 타입인 Settings 타입이 추가되었다. 이는 기본적인 설정 윈도우와 관련 단축키도 제공한다.

그리고 우린 기본 단축키 이외의 단축키도 새 변경자 API를 통해 지원할 수 있다.


해당 세션을 통해 앱과 관련하여 새로 추가된 기능 및 API들에 대해 간단히 알아볼 수 있었다. 길지 않은 세션이기 때문에 출,퇴근길에 간단하게 시청할 수 있는 세션이었다.

SwiftUI under the hood

요즘 SwiftUI를 공부하면서 느낀 점은 마냥 쉽지만은 않다는 것이다. 흔히 SwiftUI를 소개할 때 매우 쉽게 UI를 그릴 수 있는 프레임워크로 소개하곤 하는데, 완전히 틀린 말은 아니지만 그렇다고 완전히 맞는 말도 아닌 것 같다. SwiftUI를 공부하면서 처음 iOS를 공부했을 때, 더 나아가 처음 프로그래밍을 공부했을 때가 종종 생각나곤 한다.

당시 처음으로 프로그래밍 언어를 가르쳐 주신 강사님으로부터 기본적인 C 언어 문법을 배우고 모든 걸 배운 것처럼 기세등등했던 적이 있다. 자신감이 한없이 하늘을 찌르고 있을 때 강사님은 다음과 같은 말씀을 해주셨다.

*”원래 처음 프로그래밍을 배우고 기본 문법을 마친 사람들이 프로그래밍을 매우 쉽다고 생각하고 자만하게 됩니다. 하지만 공부를 하면 할수록 어렵게 느껴지고 자신감이 떨어질 수 있는 공부가 프로그래밍입니다.”*

비단 프로그래밍에만 국한되는 이야기는 아닌 것 같다.

당시에는 이해가 가질 않았으나, 본격적으로 학부 공부를 시작하고 취업 준비를 하면서 저 말이 계속해서 떠올랐고 자신감이 자주 떨어지곤 했다. SwiftUI를 공부하면서도 마찬가지였다. 처음 VStack, List 등을 사용하면서 그 간편함에 놀라고 매우 쉽다고 생각했다. 하지만 데이터의 흐름과 레이아웃이 결정되는 방식 등 깊게 파고들면 파고들수록 머릿속은 여러 개념들이 완전하지 않은 채 뒤엉키게 되었다. 그렇게 혼란스러워하던 중 한 컨퍼런스에서 Chris Eidhof가 “SwiftUI under the hood”란 주제로 발표한 영상을 보고 복잡하고 산발되어 있던 개념들이 어느 정도 정리가 되면서 여러 개념들의 존재 이유와 목적에 대해 감을 잡을 수 있었다. 오늘은 그 영상의 내용을 요약 및 정리해보는 시간을 가지려 한다.

영상 : Chris Eidhof - SwiftUI under the hood

영상에서 다루는 큰 주제는 레이아웃 알고리즘이다. 즉 SwiftUI가 어떤 방식으로 레이아웃을 그리는지에 대해 다룬다. 이를 설명하면서 GeometryReader, Preference 등 중요한 개념들이 자연스럽게 등장하며 설명을 돕는다.

서론이 너무 길었다. 바로 시작해보자.


Layout Algorithm

파란색 배경의 원 안에 텍스트가 존재하는 뷰를 만든다고 상상해보자. 원이 아닌 다양한 도형이 될 수 있다. 즉 매우 흔한 상황이다. 우리가 원하는 결과물은 다음과 같을 것이다.

매우 쉬워 보인다! 그럼 코드로 이를 만들어보자.

1
2
3
4
5
Text("Reset")
.background(
Circle()
.fill(Color.blue)
)

기대하던 모습이 나올까? 그렇지 않다. 그 모습은 우리가 기대하던 모습과 거리가 멀다.

이유가 무엇일까? 그 이유를 알기 위해선 SwiftUI의 레이아웃 알고리즘에 대한 이해가 있어야 한다. 모든 레이아웃은 다음 4가지 단계를 통해 그려진다.

  1. 컨테이너 뷰(직계 상위 뷰)가 사이즈를 제안한다.
  2. 하위 뷰가 자신의 사이즈를 결정한다. (하위 뷰는 상위 뷰가 제안한 사이즈를 그대로 사용하거나, 본인이 자신의 사이즈를 결정하기도 한다.)
  3. 하위 뷰가 컨테이너 뷰에 자신의 사이즈를 알린다. (2단계에서 결정된 사이즈로 컨테이너 뷰의 사이즈가 정해진다.)
  4. 컨테이너 뷰가 하위 뷰를 가운데 정렬 시킨다. (alignment)

이는 아주 기본적인 절차이고 여러 값들의 재정의를 통해 변경될 수 있다. 그럼 이 알고리즘을 바탕으로 위의 코드를 분석해보자. 먼저 위의 코드로 그려진 뷰의 계층을 뷰 디버깅을 통해 살펴보고 아래 설명을 따라가면서 이해해보자.

  1. 먼저 루트 뷰(root view)가 화면 전체 사이즈를 background에게 제안한다. 그리고 background는 이를 다시 Text에게 전달한다.

    Modifier는 새로운 뷰를 만들고 이전 뷰를 감싸서 반환한다는 것을 기억한다면 backgroundText를 감싸고 있다는 것을 이해할 수 있다.

  2. 하지만 Text는 자신이 담고 있는 내용만큼만 필요하기 때문에 "Reset"만큼만 사용할 것을 backgroud에 알린다.

  3. 그럼 다시 background는 이를 하위 뷰인 ShapeView에 알리고 ShapeView는 이를 Circle에게 알린다. (ShapeViewText는 형제-자매(sibling) 관계다.) Circle는 받은 크기(Text의 크기)를 그대로 사용해서 그 크기에 딱 맞는 원을 그린다.

  4. 그리고 이를 background에게 전달하고 background는 다시 이 크기를 루트 뷰에 전달하고 루트 뷰는 해당 뷰를 가운데 정렬한다.

뷰 계층과 알고리즘을 따라가보면 왜 우리가 원하는 레이아웃이 나오지 않았는지에 대해 이해할 수 있다. 그럼 우리가 원하는 레이아웃을 그리기 위해선 어떻게 해야 할까?

가장 먼저 떠오르는 생각은 frame 변경자를 사용하는 것이다. 사용하기에 앞서 frame 변경자의 특징을 알아야 한다. frame 변경자는 상위 뷰에서 오는 크기 정보나, 하위 뷰에서 알려주는 크기 정보를 모두 무시하고 자신의 인자로 넘어온 크기 정보만 사용한다. 이 점을 기억하고 아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
Text("Reset")
//.frame(width: 75, height: 75) // --- 1
.background(
Circle()
.fill(Color.blue)
// .frame(width: 75, height: 75) // --- 2
)
//.frame(width: 75, height: 75) // --- 3

frame 연산자를 1, 2번 두 곳 중 한 곳에만 위치시켜도 원하는 레이아웃을 그릴 수 있다. 하지만 두 곳 모두 문제점을 갖고 있다.

  1. 위에서 언급했듯이 frame은 하위 뷰에서 알려준 크기 정보도 무시한다. 즉 Text가 정한 크기도 무시한다는 뜻이다. 그렇기 때문에 이곳에 frame을 위치시면 길어진 문자열 크기를 Text가 아무리 frame에 알려도 frame은 자신의 정보로 크기를 결정하기 때문에 문자열은 75x75 영역 안에서 벗어날 수 없고, 해당 영역 안에 표시되지 못한 부분은 말 줄임표(...)로 나타나게 된다.

  2. frame 영역은 Circle에만 영향을 주기 때문에 문자열이 길어지면 원의 영역을 벗어난다.

frame을 3번에 위치시키면 어떻게 될까? frame이 이 크기 정보(75x75)를 background에게 background가 다시 Text에 전달하지만 Text는 자신이 담고 있는 내용만큼만 사용한다고 background에 알리고 background는 이 정보를 다시 Circle에 전달하기 때문에 결과적으로 위에서 봤던 레이아웃과 동일한 것을 육안으로 확인할 수 있다.

육안으로 확인한 레이아웃이 같다고 실제로 그 둘이 같은 것은 아니다. 전체를 감싸고 있는 뷰의 크기 차이가 존재한다(파란 테두리가 감싸고 있는 영역). 그 이유는 위에서 언급했듯이 frame은 하위 뷰에서 보낸 정보도 무시하기 때문에 자신의 크기 정보(75x75)를 사용한다. 그렇기 때문에 전체를 감싸고 있는 뷰의 크기에 차이가 생기는 것이다.

문제를 해결하기 위해선 frame 안에 들어갈 값은 문자열의 길이에 따라 동적으로 변경되어야 한다. 정확히는 Text의 크기에 따라 변경되어야 한다. 그럼 Text의 크기는 어떻게 알 수 있을까? UIKit을 사용할 땐 객체에 직접 접근해 값을 가져올 수 있었다. 하지만 SwiftUI에선 불가능하다. 우리는 이 문제점을 GeometryReader를 사용해 해결해보려 한다.

GeometryReader

GeometryReader는 SwiftUI에서 중요한 개념이지만 이번 포스팅에선 현재 상황에서 GeometryReader가 해결할 수 있는 부분에 대해서만 간단하게 설명해보고자 한다.

GeometryReader는 컨테이너 뷰의 한 종류로 자신의 직계 상위 뷰의 기하학(Geometry) 정보(좌표, 크기 등)를 자신이 포함하는 자식 뷰에게 제공하는 역할을 한다. 그럼 바로 사용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
Text("Reset")
.padding()
.fixedSize()
.background(
GeometryReader { proxy in
// proxy.size.width == Text("Reset")'s width
}
)
.frame(width: 75, height: 75)
.background(
Circle()
.fill(Color.blue)
)

fixedSize 변경자는 Text에만 존재하는 변경자로 아무리 문자열이 길어져도 1줄에 보여주도록 강제한다. 이를 사용하면 위에서 보았던 이미지처럼 주어진 크기 안에서 문자열이 길어질 때 여러 줄과 함께 말 줄임표로 보여주는 것이 아니라 영역을 벗어나더라도 문자열을 1줄에 보여줄 수 있다.

단순히 직계 상위 뷰의 정보를 받아온다고 생각하지 말고 위에서 살펴보았던 레이아웃 결정 과정을 대입해서 생각해보자.

  1. background는 루트 뷰로부터 크기를 제안받는다.
  2. background는 그 크기를 Text에게 제안한다.
  3. Text는 자신이 포함하는 내용만을 담을 수 있는 크기를 사용하기로 결정하고 이를 background에게 알린다.
  4. background는 그 정보를 GeometryReader에게 알린다.

이런 순서로 GeometryReader는 직계 상위 뷰의 정보를 받아올 수 있는 것이다. 정확히 표현하자면 Text에 의해 결정된 크기를 사용하는 background의 크기 정보를 받아온 것이다. 그럼 우린 이 크기 정보를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@State private var width: CGFloat? = nil

Text("Reset")
.padding()
.fixedSize()
.background(
GeometryReader { proxy in
self.width = proxy.size.width
}
)
.frame(width: self.width, height: self.width)
.background(
Circle()
.fill(Color.blue)
)

위와 같이 코드를 작성할 수 있을 것 같지만 실제로 이렇게 작성하면 에러가 발생한다. 왜냐하면 GeometryReader는 생성자로 @ViewBuilder를 받기 때문에 self.width = proxy.size.width와 같은 코드는 @ViewBuilder 안에 작성할 수 없다.

우린 이 시점에서 프록시(GeometryProxy)에 담긴 크기 정보를 바로 width 프로퍼티에 할당할 수 없다. 우린 이 정보를 뷰 계층 위로 전달해야 한다. 이렇게 하위 뷰에서 상위 뷰로 정보를 전달하기 위해서 사용하는 것이 바로 Preference다.

Preference

Preference 역시 GeometryReader와 마찬가지로 굉장히 중요한 개념 중 하나이다. 하지만 마찬가지로 현재 상황에서 Preference가 해결할 수 있는 부분에 대해서만 간단하게 설명해보고자 한다.

Preference는 키-밸류 메커니즘으로 하위 뷰 정보를 상위 뷰에 전달할 수 있는 수단이다. 이를 위해선 먼저 PreferenceKey 프로토콜을 따르는 키를 정의해주어야 한다.

1
2
3
4
5
struct SizeKey: PreferenceKey {
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue()
}
}

간단하게 설명하자면 reduce 메소드는 SizeKey를 사용하는 하위 뷰들을 순회하면서 상위 뷰가 접근할 수 있는 값을 만들기 위해 이들의 값(SizeKey를 사용하는 하위 뷰의 값) 취합하는 역할을 한다.

이번 포스팅에선 단순히 PreferenceKey의 기능만을 소개하지만 추후 포스팅에서 더욱 자세히 다룰 예정이다. 하지만 당장 궁금하다면 이 글을 참고하면 좋을 것이다.

그리고 간단한 트릭(?)을 사용해서 우린 뷰 계층 위로 proxy 정보를 전달할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct ContentView: View {
@State private var width: CGFloat? = nil

var body: some View {
Text("Reset")
.padding()
.fixedSize()
.background(
GeometryReader { proxy in
Color.clear.preference(key: SizeKey.self, value: proxy.size.width) // --- 1
}
)
.frame(width: self.width, height: self.width)
.background(
Circle()
.fill(Color.blue)
)
.onPreferenceChange(SizeKey.self) { value in // --- 2
self.width = value
}
}
}
  1. ColorView 프로토콜을 따른다. 그렇기 때문에 사용자는 볼 수 없는 Color.clearpreference를 통해 proxy.size.width를 전달하고 있다.
  2. 상위 뷰(Text)에선 하위 뷰에서 전달한 정보를 onPreferenceChange를 통해 받을 수 있다.

이제 진짜 우리가 원하던 레이아웃을 확인할 수 있을 것이다.


정리하며

간단한 레이아웃(?)을 그려보면서 SwiftUI를 관통하는 여러 개념들을 자연스럽게 접해볼 수 있던 영상이었다. SwiftUI가 레이아웃을 그리는 알고리즘에 대해 이해하고 나니 왜 그렇게 그려지는지 이해가 가지 않았던 부분들이 어느정 도 머릿속에 정리가 되는 시간이었다.

영상 말미에 한 가지 과제(?)를 주는데 이 부분은 github에 올려놓았습니다.

SwiftUI에서 여러 타입의 뷰 반환하기

지난 포스팅에서 불투명 타입(Opaque Type)에 대해 공부했었다.

글 : Opaque Types in Swift

당시 우리는 불투명 타입을 반환할 땐 타입 정체성(Identity)를 잃지 않기 위해 한 가지 타입만 반환해야 한다고 공부했다. 하지만 개발을 하다 보면 여러 예외 상황을 마주하게 되는데 이는 불투명 타입도 마찬가지다.

먼저 SwiftUI로 프로젝트를 생성했을 때 마주하게 되는 기본 ContentView 코드를 살펴보자.

1
2
3
4
5
struct ContentView: View { 
var body: some View {
return Text("Hello World")
}
}

body 프로퍼티는 some View 타입이기 때문에 한 가지 타입만 반환할 수 있다. 그래서 뷰 계층을 작성할 때 우리는 아래와 같은 구조에 익숙하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView: View { 
var body: some View {
...View {
...View {
...View {
...View {

}
}
}
}
}
}

some View를 반환해야 하기 때문에 위와 같은 구조의 코드에 익숙하다. 하지만 조건에 따라 다른 타입의 뷰를 보여주어야 한다면 어떻게 해야 할까?

1
2
3
4
5
6
7
8
9
struct ContentView: View { 
var body: some View {
if isLogIn {
Image("User-Avatar")
}else {
Text("Please Login")
}
}
}

이렇게 코드를 작성하면 우리는 Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type와 같은 에러 메시지를 볼 수 있다.

하지만 언제나 방법은 있다! 게다가 두 가지씩이나! 그럼 그 두 가지를 살펴보자.

Group

Group은 어떠한 레이아웃 특성도 지니지 않는다. Group 안에서는 위와 같이 조건 분기를 통한 두 가지 타입을 반환할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View { 
var body: some View {
Group {
if isLogIn {
Image("User-Avatar")
}else {
Text("Please Login")
}
}
}
}

오늘의 주제와 별개로 Group의 사용 예를 한 가지 더 들어보자면 기본적으로 뷰 빌더 안에는 최대 10개 뷰만 들어갈 수 있다. 10개가 넘어가면 에러 메시지를 출력하는데, 이때 우리는 Group을 사용해 레이아웃을 해치지 않으면서 10개 이상의 뷰를 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct ContentView: View { 
var body: some View {
VStack {
Group {
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
}

Group {
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
}
}
}
}

AnyView

Group 말고도 우린 AnyView를 통해 두 가지 타입의 뷰를 반환할 수 있다. 정확하게는 타입을 지워(Type erase) 하나의 타입이 반환되는 것처럼 보이게 하는 것이다.

1
2
3
4
5
6
7
8
9
struct ContentView: View { 
var body: some View {
if isLogIn {
return AnyView(Image("User-Avatar"))
}else {
return AnyView(Text("Please Login"))
}
}
}

하지만 AnyView는 성능 비용이 높기 때문에 사용하는 것을 지양한다. AnyView의 공식 문서에는 다음과 같은 내용이 있다.

Whenever the type of view used with an AnyView changes, the old hierarchy is destroyed and a new hierarchy is created for the new type

AnyView와 함께 사용된 뷰가 변할 때마다 이전 뷰 계층을 파괴하고 새로 만들기 때문에 이 부분에서 성능 비용이 높다고 설명하는 것 같다.

하지만 또 이 부분에 대해 Group과 성능 비교를 한 글도 확인할 수 있었다. 이 비교 실험 글에 따르면 사실상 큰 차이는 없다고 나온다. 또한 이 글에선 body에서 뷰 상태의 바인딩을 최소한으로 하는 것이 성능 향상에 더 도움이 된다고 언급하고 있다.

상태에 의존하는 뷰가 많을수록 rebuild 횟수가 증가할 가능성이 크기 때문에 이렇게 말하고 있는 것으로 생각된다.


오늘은 SwiftUI에서 여러 타입의 뷰를 반환하는 두 가지 방법에 대해 알아보았다.

Property Wrappers in Swift

Property wrapper는 프로퍼티를 정의하는 코드(code that defines a property)와 프로퍼티가 어떻게 저장되는지를 관리하는 코드(code that manages how a property is stored) 사이의 계층(layer)이다.

Property wrapper를 사용하면 정의할 때 관리 코드(management code)를 한 번만 작성하고 여러 프로퍼티에 재사용할 수 있다. 사실 이런 wrapper 개념은 이번에 갑자기 나타난 것이 아니다. lazy@NSCopying 같은 키워드도 wrapper의 한 종류다. 하지만 Swift 5.1에선 개발자가 이런 wrapper를 직접 만들 수 있게 되었다.

Property wrapper를 이해하기 위해선 Property wrapper가 어떤 문제를 해결할 수 있는지를 보면 된다. 객체 안의 프로퍼티에 값이 할당될 때마다 로그를 출력해주어야 한다고 가정해보자. 우린 다음과 같이 코드를 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Bar {
private var _x = 0

var x: Int {
get { _x }
set {
_x = newValue
print("New value is \(newValue)")
}
}
}

var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

가장 직관적인 방법이다. 하지만 객체 안에 x뿐만 아니라 수많은 프로퍼티가 존재하고 이런 프로퍼티 모두가 값이 할당될 때마다 로그를 출력해주어야 한다면 상황은 달라진다.

이런 상황을 해결하기 위해 우린 새로운 타입을 정의해줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ConsoleLogged<Value> {
private var value: Value

init(wrappedValue: Value) {
self.value = wrappedValue
}

var wrappedValue: Value {
get { value }
set {
value = newValue
print("New value is \(newValue)")
}
}
}

그리고 기존의 Bar 구조체를 아래와 같이 ConsoleLogged 타입을 사용해 바꿀 수 있다.

1
2
3
4
5
6
7
8
9
10
11
struct Bar {
private var _x = ConsoleLogged<Int>(wrappedValue: 0)

var x: Int {
get { _x.wrappedValue }
set { _x.wrappedValue = newValue }
}
}

var bar = Bar()
bar.x = 1 // Prints 'New value is 1'

이렇게 ConsoleLogged 타입을 사용해 중복 코드를 줄일 수 있다. Swift 5.1에선 이런 패턴을 Property wrapper라는 Syntatic Sugar로 제공한다. 사용 방법은 매우 간단하다. 기존의 ConsoleLogged@propertyWrapper만 붙이면 된다. @propertyWrapper를 타입(struct, class, enum) 앞에 붙이고 wrappedValue만 정의해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@propertyWrapper
struct ConsoleLogged<Value> {
private var value: Value

init(wrappedValue: Value) {
self.value = wrappedValue
}

var wrappedValue: Value {
get { value }
set {
value = newValue
print("New value is \(newValue)")
}
}
}

프로퍼티가 어떻게 저장되는지를 관리하는 코드(code that manages how a property is stored)

이렇게 선언한 Property wrapper는 사용하기도 쉽다. 아래와 같이 두 가지 방법으로 Property wrapper를 사용할 수 있다.

1
2
3
4
struct Bar { 
@ConsoleLogged var x = 0 // 1
@ConsoleLogged(wrappedValue: 2) var y // 2
}

프로퍼티를 정의하는 코드(code that defines a property)

Property wrapper 안에는 wrappedValue와 같이 프로퍼티뿐만 아니라 메소드도 정의할 수 있다. 하지만 안에 정의된 메소드를 사용할 땐 스코프(scope)에 주의할 필요가 있다.

1
2
3
4
5
6
@propertyWrapper
struct Wrapper<T> {
var wrappedValue: T

func foo() { print("Foo") }
}

위와 같이 Property wrapper안에 foo라는 메소드를 정의했다.

1
2
3
4
5
struct HasWrapper {
@Wrapper var x = 0

func foo() { _x.foo() }
}

그럼 당연히 Wrapper를 Property wrapper로 사용하는 객체 내부에선 foo 메소드를 사용할 수 있다. (_x.foo())

1
2
let a = HasWrapper()
a._x.foo() // ❌ '_x' is inaccessible due to 'private' protection level

하지만 위와 같이 외부에서의 접근은 불가능하다. 이유는 private 수준의 접근 제어를 갖기 때문이다. 물론 외부에서 접근할 수 있는 방법은 존재한다. projectedValue를 사용하면 된다.

우리는 projectedValue를 통해 부수적인 API를 외부에 노출시킬 수 있다. wrappedValue와 다르게 projectedValue는 타입의 제한이 없다.

1
2
3
4
5
6
7
8
@propertyWrapper
struct Wrapper<T> {
var wrappedValue: T

var projectedValue: Wrapper<T> { return self }

func foo() { print("Foo") }
}

그리고 우리는 projectedValuex$을 붙여 접근할 수 있다.

1
2
let a = HasWrapper()
a.$x.foo() // Prints 'Foo'

SwiftUI엔 @State, @Binding과 같은 빌트인 Property wrapper가 존재한다. 이에 대해선 추후 다루게 될 SwiftUI 포스팅에서 하나씩 살펴보도록 하자.

이번 포스팅에선 간단한 예로 UserDefaults를 Property wrapper를 통해 보다 간편하게 사용할 수 있는 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
@propertyWrapper
struct UserDefault<T> {
var key: String
var initialValue: T
var wrappedValue: T {
set { UserDefaults.standard.set(newValue, forKey: key) }
get { UserDefaults.standard.object(forKey: key) as? T ?? initialValue }
}
}

먼저 UserDefault라는 Property wrapper를 위와 같이 정의했다. initialValue를 통해 값이 존재하지 않을 때 초기값을 제공할 수 있다. 그리고 아래와 같이 사용할 수 있다.

1
2
3
4
5
6
7
8
9
enum UserPreferences {
@UserDefault(key: "isCheatModeEnabled", initialValue: false) static var isCheatModeEnabled: Bool
@UserDefault(key: "highestScore", initialValue: 10000) static var highestScore: Int
@UserDefault(key: "nickname", initialValue: "cloudstrife97") static var nickname: String
}

UserPreferences.isCheatModeEnabled = true
UserPreferences.highestScore = 25000
UserPreferences.nickname = "squallleonhart"

물론 Property wrapper로 선언된 프로퍼티에도 한계가 존재한다.

  • 하위 클래스에서 오버라이딩될 수 없다.
  • lazy, weak, @NSCopying과 같이 사용할 수 없다.
  • 커스텀 set, get을 사용할 수 없다.
  • 프로토콜이나 익스텐션에서 선언될 수 없다.

오늘은 Swift 5.1에 새로 추가된 Property wrapper에 대해 매우 간단히 알아보았다. 이후에는 SwiftUI에서 사용되고 있는 Property wrapper들에 대해서 소개해보려 한다.


참고 자료

SwiftUI Essentials (2)

Building custom views

SwiftUI로 커스텀 뷰를 만드는 방식에 대해 이야기 해보자.

주문 내역을 보여주는 OrderHistory를 살펴보자.

위의 코드에서 먼저 살펴볼 부분은 바로 View 프로토콜을 따르고 있는 OrderHistory가 구조체로 선언되어 있다는 점이다.

일반적으로 UIKit으로 뷰를 만들면 프로토콜을 따르는 구조체가 아닌 공통 부모 클래스로부터 상속받는 클래스를 작성하곤 한다. OrderHistory를 UIKit으로 만든다면 다음과 같은 상속 관계를 가질 것이다.

UIViewalphabackgroundColor 같은 공통된 저장 프로퍼티(stored property)를 갖고 있다. OrderHistory는 자신의 previousOrders 프로퍼티와 더불어 부모 클래스의 프로퍼티까지 갖고 있게 된다. 반면에 SwiftUI는 어떨까?

SwiftUI는 이런 공통된 저장 프로퍼티를 분리된 변경자로 관리하고 각각의 변경자는 자신들만의 뷰를 생성하게 된다. 그러므로 공통된 저장 프로퍼티는 뷰 계층 전반에 걸쳐 분산된다. 이러한 방식으로 뷰를 더 가볍게 해서 각 뷰의 고유 목적에 맞게 최적화한다.

이러한 방식 때문에 SwiftUI에서 뷰가 프로토콜이 된다고 할 수 있는 것이다.

그럼 뷰는 무엇을 하는 것일까?

뷰는 단지 UI의 한 조각을 정의할 뿐이고 우린 이런 뷰들을 이용하고 재사용하여 뷰 계층을 구성하는 것이다. View 프로토콜의 살펴보자.

위의 코드를 보고 있으면 어떤 생각이 드는가? 재귀적이지 않은가?

하나의 뷰가 있고 그 뷰의 body가 다른 뷰를 나타내고 그 뷰의 body가 또 다른 뷰를 나타내는 이런 구조를 보일 수 있다고 생각할 수 있는데 이는 지속되지 않는다. 그 이유는 SwiftUI가 스스로 컨텐츠를 갖지 않고 다른 뷰를 구성하는 아토믹(atomic)한 뷰인 원시 뷰(primitive view)를 제공하고 위와 같은 body 사슬의 끝은 결국 이런 원시 뷰이기 때문이다.

우리가 위에서 보았던 TextImage와 더불어 드로잉에 사용되는 ColorShape, 레이아웃에 사용되는 Spacer와 같은 다양한 원시 뷰를 제공한다.

다시 OrderHistory로 돌아와 클래스가 아닌 구조체로 정의된 것에 주목해보자. 클래스로 정의한 것이 아니기 때문에 OrderHistory는 더 이상 이벤트 기반으로 동작하는 명령형 코드로 갱신되는 영구적인 객체가 아니다.

대신 뷰는 Input에 따라 결과가 달라지는 함수와 같이 선언형 코드로 정의된다. 이 말은 Input이 변경되면 SwiftUI가 body 프로퍼티를 다시 호출해서 뷰를 갱신한다.

만일 이벤트 기반의 명령형 코드였다면 Input의 변경(삭제, 삽입 등)에 따른 갱신 코드를 작성해주어야 했는데, SwiftUI에서는 선언형 코드로 인풋이 변경되면 SwiftUI가 내부적으로 이전 데이터와 새 데이터를 비교해서 무엇이 변경되었는지를 비교 후 효율적으로 뷰를 갱신하게 된다.

OrderHistory 코드를 계속해서 살펴보자. 조건에 따라 뷰의 유무를 표시할 때 우리는 다음과 같이 뷰 빌더 클로저 안에 조건문을 통해 이를 구현할 수 있다.

하지만 이런 조건문도 상황에 따라 제대로 사용해야 한다. 다음 상황의 코드를 살펴보자.

flipped 값에 따라 아이콘의 각도를 다르게 보여주고 싶을 때 위와 같이 작성할 수 있다. 하지만 이는 잘못된 방법이다. 이런 코드는 부자연스러운 애니메이션을 만들게 된다. 이 코드는 SwiftUI에게 서로 다른 뷰 중 하나를 선택하게 하는 것이고 이는 곧 뷰의 추가와 삭제를 의미한다. 뷰의 추가와 삭제는 fade 애니메이션이 적용되기에 부자연스러운 애니메이션을 보게 되는 것이다.

우리가 원하는 자연스러운 애니메이션을 위해선 다음과 같이 코드를 작성해야 한다.

여기서 얻을 수 있는 교훈은 이런 조건에 따라 다른 값에 의한 뷰의 변화를 부드러운 애니메이션을 통해 제공하기 위해선 최대한 이를 변경자 내부에 위치시켜 SwiftUI가 변화를 감지하여 보다 부드러운 애니메이션을 제공하도록 해야 한다는 것이다.

또한 비대해진 OrderHistory를 우린 더 작은 뷰로 나누어 관리할 수도 있다.

만일 OrderHistory에 조건에 따라 또 다른 뷰가 추가되어야 한다면 코드를 어떻게 작성해야할까

위와 같은 방법은 확장성이 매우 떨어진다. 우리는 이런 상황에서 ForEach 뷰를 사용할 수 있다.

ForEach는 하나의 뷰로 List와 마찬가지로 콜렉션 데이터 타입을 인자로 받는다. 그리고 뷰 빌더 클로저 안에 뷰를 나열하는데 이때 나열된 뷰는 ForEach에 추가되지 않고 ForEach의 상위 뷰에 추가된다.

지금까지 작성된 코드들을 보면 우리가 직접 작성하지 않고도 SwiftUI가 스스로 그리고 반응하며 갱신하는 것을 확인할 수 있었다. 이것이 바로 선언형 코드의 장점이라 할 수 있다.

Composing Controls

아보카도 토스트 주문을 넣는 화면을 다시 살펴보자. 이는 우리가 알고 있는 화면과 많이 다르다. 정확히 말하자면 정형화되지 않은 상태다. 이 뷰를 아래와 같이 우리가 익숙한 형태의 뷰로 변경해보자.

둘의 가장 큰 차이점은 컨테이너가 다르다는 것이다.

기존 뷰(왼쪽)의 컨테이너가 VStack이라면 우리가 익숙한 오른쪽 뷰의 컨테이너는 Form이다. Form 역시 뷰 컨테이너의 한 종류다. VStack과의 차이점에는 헤더, 섹션 등이 있어 보다 정형화된 그룹 스타일의 UI를 보다 쉽게 만들 수 있다.

그리고 이렇게 컨테이너가 바뀜에 따라 그 안에 속하는 컨트롤(버튼, 토글 등)도 그 모습이나 속성이 컨테이너에 따라 변한다. 또한 Form을 사용하면 서로 다른 플랫폼에서 다양한 룩앤필(Look and Feel)을 제공할 수 있다. 이렇게 SwiftUI가 UI를 그리기 때문에 우리는 기능에 보다 집중할 수 있다.

위의 화면에서 Button을 예로 들면 뷰 컨테이너가 바뀌면서 Buttonpadding, alignment 등이 바뀐 것을 확인할 수 있다.

이번엔 Button 코드를 살펴보자.

위의 단일 코드로 여러 플랫폼에서 다양한 룩앤필을 제공할 수 있다.

Button은 눌렸을 때 액션을 인자로 넣어주고 버튼의 상태와 목적을 나타내는 label을 뷰 빌더 클로저를 통해 제공해줄 수 있다. 그리고 앞에서 봐왔듯이 여러 변경자들을 통해 보다 쉽게 커스터마이징을 할 수 있다. 이를 통해 우리는 다양한 플랫폼의 다양한 버튼을 사용자에게 제공해줄 수 있다.

그렇기 때문에 SwiftUI에서 컨트롤은 적응형(adaptive) 컨트롤이라 할 수 있다. 적응형 컨트롤은 다음과 같은 특성을 갖는다.

컨트롤은 그 자체로 모양이 아닌 역할을 나타낸다. 이렇게 컨트롤이 역할을 의미하기 때문에 여러 플랫폼에 거쳐 재사용될 수 있는 것이다.

이렇게 컨트롤들은 역할이 있고 이런 역할은 목적에 의해 생겨나기 때문에 Toggle이나 Button들은 그들 각자의 목적이 존재한다. 그리고 이들은 사람이 읽을 수 있는 레이블을 포함하기 때문에 기본적으로 VoiceOver 기능을 지원한다.

그리고 레이블이 Text가 아니라 Image어도 Image에 설명을 위한 Text를 함께 제공하여 VoiceOver 기능을 제공할 수 있다.

또한 커스텀 뷰는 accessbility 변경자를 통해 이런 기능을 제공할 수 있다.

이렇게 컨트롤은 플랫폼에 따라 모양은 다를 수 있지만 본연의 목적을 수행하는 데 이는 SwiftUI의 핵심이라고 할 수 있다. SwiftUI는 한 번만 작성하고 어디에서나 실행할 수 있는 수단 일뿐만 아니라 이러한 핵심 개념을 배우고 다양한 컨텍스트와 플랫폼에서 사용할 수 있는 프레임 워크다.

그리고 우리가 뷰에서처럼 컨트롤에서도 변경자를 사용할 수 있다. 그리고 이는 뷰에서와 동일한 특성을 갖는다.

예를 들어 다음과 같이 컨트롤 계층 전반에 걸쳐 변경자를 공유할 수 있다.

다음으로 살펴볼 것은 환경(Environment)이다. 이는 일종의 모든 뷰에서 접근할 수 있는 특성의 집합으로 볼 수 있다. 그리고 자식 뷰는 부모 뷰의 환경 특성을 상속 받는다. 물론 필요에 따라 자식 뷰에서 이를 오버라이딩할 수 있다.

그리고 이 환경은 프리뷰에서 유용하게 사용되는데, 동일한 UI를 여러 문맥에 따라 다르게 보여주는 기능을 제공한다. 이를 통해 환경, 문맥에 따라 UI가 어떻게 바뀌는지 쉽게 확인할 수 있다.

iOS에선 기본적으로 NavigationView를 통해 기본 내비게이션 스타일을 사용할 수 있으며 navigationBarTitle 변경자를 통해 타이틀을 지정할 수 있다.

navigationBarTitle은 다른 변경자와 같이 아래를 향하지 않고 위를 향하는 특성을 갖는다. OrderForm에 변경자를 적용했지만, NavigationView에 반영된다는 것을 의미한다.

그리고 NavigationButton를 목적지와 함께 만들어 실질적인 화면 전환을 구현할 수 있다.

TabbedView를 통해 성격이 다른 두 뷰를 탭 뷰로 묶어 관리할 수도 있다.