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](

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 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를 통해 성격이 다른 두 뷰를 탭 뷰로 묶어 관리할 수도 있다.

SwiftUI Essentials (1)

Views and modifiers

View들은 UI를 구성하는 가장 기본적인 블록이다. UIKit의 UIView나 AppKit의 NSView와 같이 UI를 구성하는 기본적인 단위라고 할 수 있다.

다음 앱의 UI를 계층 구조로 살펴보자.

이 계층을 SwiftUI로 작성하면 다음과 같다.

SwiftUI는 이러한 뷰의 계층을 코드로 표현한다. 왼쪽의 코드 구조는 오른쪽의 뷰 계층 구조와 상당히 흡사한 것을 확인할 수 있다.

또한 코드에서 살펴볼 수 있듯이 뷰 계층을 표현하는데 addSubview와 같은 메소드를 사용하지 않는다. SwiftUI는 하나의 계층 구조를 각 뷰 조각들로 구성하는 것이 아니라 계층 전체를 하나의 완전한 구조로 생성한다. 왜냐하면 SwiftUI는 뷰를 명령형(imperatively)과 반대인 선언형(declaratively)으로 정의하고 있기 때문이다.

명령형과 선언형의 차이점을 살펴보자.

  • 명령형 코드 : 명시적인 명령(explicit commands)을 통해 결과를 구성
  • 선언형 코드 : 묘사(describing) 통해 결과를 구성. 단 이를 어떻게 생성할지는 다른 주체에 의해 결정

둘의 차이가 정의로만은 부족할 수 있다. 상황을 예를 들어 둘의 차이를 살펴보자.

명령형 코드는 친구에게 아보카도 토스트를 만드는 방법을 알려주는 것과 같다.

선언형 코드는 아보카도 토스트를 만드는 요리사에게 토스트 주문을 하는 것과 같다.

친구에게 토스트 만드는 방법을 설명할 때는 7. 아보카도의 중심을 제거해라.와 같이 내가 직접 단계별로 필요한 결과를 전달한다.

반면, 요리사에게는 내가 원하는 토스트의 모습을 묘사하여 전달하고 그것을 어떻게 만드는지는 전적으로 요리사의 몫이다. 그리고 요리사가 전문가라면 우리는 항상 최상의 품질을 보장받을 수 있다.

이 두 상황을 통해 명령형 코드와 선언형 코드의 차이점을 보다 쉽게 이해할 수 있었다.

SwiftUI가 요리사의 역할을 하는 것이다. 그럼 이제 SwiftUI의 요소들을 하나씩 살펴보자.

View Container Syntax

뷰 컨테이너는 여러 다른 컨텐트 뷰(Content View)들로 구성되어 있다. 뷰 컨테이너에는 VStack, HStack 등이 존재한다. 뷰 컨테이너의 일반적인 문법은 다음과 같다.

VStack을 다음과 같이 사용할 수 있는 것과 같다.

1
2
3
4
5
VStack { 
Imgae(...)
Text(...)
Text(...)
}

Image, Text와 같은 컨텐트 뷰들은 뷰 빌더(View Builder)라는 클로저 안에 나열된다. 그리고 뷰 컨테이너의 생성자는 이 뷰 빌더 클로저를 인자로 받는다. addSubview와 같은 함수를 호출하는 대신 이 클로저 블록 안에 원하는 뷰를 순서대로 나열만 해주면 된다.

실제로 뷰 빌더가 내부적으로 어떻게 동작하는지 확인하기 위해 VStack API를 살펴보자.

생성자 인자 중 content@ViewBuilder 속성(attribute)이 붙어있는 것을 확인할 수 있다. 스위프트 컴파일러는 @ViewBuilder 속성이 붙어 있으면 해당 클로저를 우리가 나열한 컨텐트 뷰들이 포함된 단일 뷰를 반환하는 클로저로 변환한다.

이런 특수한 클로저를 뷰 컨테이너의 생성자에 전달해줌으로써 뷰 컨테이너와 컨텐트 뷰들은 들여 쓰기로 자연스레 구분될 수 있다.

또한 VStackalignmentspacing과 같은 인자를 추가로 받아 정렬이나 간격을 조정해줄 수 있다.

SwiftUI에서 VStack, HStack과 같이 컨트롤(Control)도 뷰 컨테이너의 종류로 다른 뷰를 컨텐트 뷰로 포함할 수 있다. Control에는 Button, Toggle Slider 등이 있다. 위의 코드에서처럼 Text뿐만 아니라 다른 뷰도 컨텐트 뷰로 포함할 수 있다.

컨트롤은 사용자와 상호작용할 수 있는 요소들을 말한다. 공식 문서를 통해 컨트롤에 어떤 것들이 있는지 알 수 있다.

컨트롤과 뷰 컨테이너는 추후에 더 자세히 살펴보도록 하고 이젠 $ 싸인에 주목해보자.

Binding Syntax

Stepper를 선언하는 코드를 살펴보자. order.quantity를 넘기는데 $ 싸인이 앞에 붙었다. 이는 단순히. order.quantity 을 넘기는 것이 아닌 바인딩을 넘기는 것이다. 그럼 여기서 말하는 바인딩이란 뭘까?

영상의 예제 앱에서 StepperOrderForm이란 뷰에 포함되어 있다.

OrderForm은 현재 순서를 추적하기 위해 Order 타입에 의존하고 있다. 이 프로퍼티를 살펴보면 @State란 속성이 붙어있는 것을 확인할 수 있다. @State 속성이 붙어있으면 SwiftUI는 이를 보고 내부적으로 지속성 있는 상태(persistent state)를 생성하고 관리하며 상태의 값을 이 프로퍼티를 통해 접근하도록 한다.

우린 이 프로터티에 접근해 상태의 값을 읽거나 쓸 수 있다.

1
Text("Quantity: \(order.quantity)")

Stepper는 정적인 뷰가 아닌 컨트롤이다. 그 말은 사용자가 Stepper의 버튼을 누르면 그 상태가 변경될 수 있다는 의미다. 이를 위해선 단순히 읽기 전용인 값을 전달하는 것이 아니라 바인딩을 전달해야 한다.

바인딩은 일종의 관리되는 참조(managed reference)로 이를 통해 하나의 뷰가 다른 뷰의 상태를 변경할 수 있다. 이 예제에선 StepperOrderForm의 상태를 $order.quantity를 통해 변경하고 있는 것이다.

SwiftUI에서의 데이터 흐름에 대한 자세한 내용은 Data Flow Through SwiftUI 영상을 참고하자.

다시 예제 앱으로 돌아와 우리가 아직 살펴보지 못한 문법을 살펴보자.

Modifier

Text("Avocado Toast")에서 우린 font(.title)과 같은 메소드를 호출할 수 있다. 이 메소드가 하는 작업은 간단하다. 호출한 뷰로부터 새 뷰를 만들어내는 것이다. SwiftUI에서 이런 메소드를 변경자(Modifier)라 부른다.

이런 변경자에 의해 뷰 계층이 어떻게 변경되는지 살펴보자. Text("Avocado Toast")을 포함하는 VStack은 다음과 같은 계층 구조를 가진다.

하지만 여기에 font(.title) 변경자를 적용하면 뷰 계층은 다음과 같이 변경된다.

이렇게 변경자로 생성된 뷰는 기존의 뷰를 감싸고 뷰 계층에 포함된다. 이런 변경자는 다수의 변경자들과 함께 체이닝될 수 있다.

이렇게 변경자를 추가하게 되면 계층 구조는 빠른 속도로 비대해진다. 우리는 이전까지 이렇게 뷰 계층이 비대해지면 성능 이슈에 대해서 고민하곤 했다. 기존의 뷰 계층은 최대한 작고 가벼워야 했다.

하지만 SwiftUI는 이러한 부분에 대한 걱정을 덜어도 된다. 위에서 언급했듯이 우리는 선언형 코드를 작성한다. 우리는 단지 원하는 모습을 묘사할 뿐이고, SwiftUI가 이를 최적화한다. 우리가 아무리 많은 변경자를 사용해 Text를 여러 뷰로 감싸도 SwiftUI가 이를 보다 효율적인 자료구조로 최적화한다. 그리고 이렇게 최적화된 자료구조는 렌더링 시스템이 렌더링 하는데 사용한다.

이렇게 변경자 체이닝 문법은 성능 이슈에 대해 걱정할 필요 없이 많은 이점을 제공한다. 그중 하나로 변경자 체이닝은 시각적 요소의 직관적인 순서를 강제한다. 즉 체이닝에 참여하는 변경자의 순서에 따라 최종 렌더링 되는 모습이 달라진다는 것이다.

만약 이런 속성들을 변경자 체이닝으로 변경하는 것이 아닌 Text의 내부에 포함된 속성이라고 가정해보자. 우린 시행착오와 문서 없이는 각각의 속성들이 어떤 순서로 적용되는지 알 수 없을 것이다. 이런 속성들을 변경자를 통해 적용함으로써 우린 순서를 명시적으로 지정할 수 있다.

또한 이런 변경자들은 여러 뷰들에서 공유될 수 있다.

이렇게 변경자를 공유함으로써 각각의 뷰들은 보다 단순해질 수 있고 자신들만의 인터페이스에 집중할 수 있다. 이것이 SwiftUI의 기본 원칙이다.

더 작고 단일 목적의 뷰라는 원칙을 따름으로써 우리는 보다 이해하기 쉽고, 유지 보수가 쉬운 뷰를 만들 수 있다.

재사용성 역시 증가한다.

그리고 이렇게 각자의 역할별로 작게 나누어진 뷰들을 통해 보다 큰 뷰를 효과적으로 구성할 수 있다.