Radio Component
SwiftUI를 사용하면 기존의 UIKit보다 효율적인 방법으로 UI를 설계할 수 있습니다. 덤으로 코드의 양을 획기적으로 줄일 수 있고 가독성 또한 높일 수 있습니다.
앞으로 UI Component를 개발하는 방법에 관한 이야기를 다뤄보겠습니다. 그 첫 번째로 이 글에서는 Radio Component를 어떻게 효율적으로 개발할 수 있는지 알아보겠습니다.
먼저 이 시리즈에서 언급하는 방법은 SwiftUI를 개인적으로 학습하며 스스로 개발한 방식이며 공론화되지 않은 방법입니다. 이 방법에는 다양한 해석이 존재할 수 있으며 더 나은 개선책을 발견할 수 있을 겁니다. 다른 관점이나 의견이 있으시면 언제든지 Fabula 프로젝트를 통해서 자유롭게 논의해 보면 좋겠습니다.
Component를 개발할 때 고민
- 자주 사용하는 UI를 Component로 만들어 재사용하고 싶다.
- 다양한 디자인을 적용할 수 있도록 자유도를 주고 싶다.
- 플랫폼에서 제공하는 API와 유사한 사용성을 제공하고 싶다.
Component로 만들기 위해서는 SwiftUI의 아키텍처를 충분히 알아볼 필요가 있습니다. 개발하다 보면 현재 알고 있는 지식 안에서 문제를 해결하느라 검증되지 않은 편법을 사용할 때가 간혹 있습니다. 이런 파편들이 쌓이고 쌓이다 보면 OS 업데이트로 인해 버그를 생산하는 원인이 될 수 있습니다. 따라서 Component를 만드는 단계로 진입하기 전에 먼저 선언적 구문을 사용하는 SwiftUI의 기본적인 메커니즘에 대해서 알아보는 것이 좋습니다.
UIKit 아키텍처의 기본은 사용자 이벤트를 받아서 처리하는 주체가 사용자라는 것입니다. 반면 SwiftUI는 기본적으로 데이터를 기반으로 바인딩 되기 때문에 더 이상 뷰 컨트롤러에서 방대한 양의 코드를 작성하거나 뷰를 업데이트하기 위한 이벤트 로직을 정의할 필요가 없습니다.
하지만 SwiftUI도 단점이 있습니다. SwiftUI의 장점이자 단점은 상태에 따라 뷰가 어떻게 자동으로 업데이트되는지 사용자가 명확하게 인지하기 어렵다는 것입니다. 이벤트 중심의 아키텍처에서 선언적 구문의 SwiftUI로 전환할 때는 이러한 불분명한 부분을 해소할 필요가 있습니다.
- 상태는 어떻게 업데이트되는지
- 뷰는 변경해야 할 사항을 어떻게 이해하는지
- 상태가 변경될 때마다 새로운 뷰를 생성하는지
- 상태 업데이트에 따라서 얼마나 많은 비용이 들어가는지
예상치 못 한 UI 버그를 만들지 않으려면 SwiftUI가 내부적으로 어떤 메커니즘을 통해서 작동하는지 충분히 이해하는 것은 중요합니다. 아래에서 하나하나 살펴보도록 하겠습니다.
View 계층 구조
SwiftUI의 UI 요소들은 View 프로토콜을 준수하는 구조체로 캡슐화 되어 있습니다.
1
2
3
4
5
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
View 프로토콜에는 View를 나타내는 Body 유형이 있습니다. View 프로토콜을 준수하기 위해서는 Body 속성을 구현해야 합니다. SwiftUI에서는 뷰를 렌더링할 때 이 Body를 요청합니다. Body 속성에 커스텀 뷰가 포함되면 중첩된 계층 구조에 따라서 Body 속성을 탐색하여 렌더링을 시도합니다. 모든 View는 Body 속성에 있는 뷰를 반환하기 때문에 기본적으로 재귀 구조로 되어 있습니다.
상태(State) 관리
사용자와 상호작용이 없는 뷰가 아니라면 대부분의 뷰에는 바인딩 된 상태(State)가 존재합니다. SwiftUI는 유형 기반의 diffing 알고리즘을 사용하여 상태에 따라 뷰를 다시 렌더링할지 결정합니다. 상태에 따라서 변경이 필요한 뷰만 렌더링을 시도하기 때문에 비용이 적고 효율적으로 관리됩니다. SwiftUI에서는 아래와 같이 상태 관리를 위해 다양한 Property Warpper를 제공하고 있습니다.
https://matteomanferdini.com/swiftui-data-flow/
관리하는 값을 읽고 쓸 수 있는 속성 래퍼 유형입니다. State 속성 래퍼를 사용하면 뷰가 변경할 수 있는 로컬 상태를 저장할 수 있습니다. State는 뷰에 대한 상태이며 변경 범위는 뷰 내부입니다. 값 유형을 임시 속성으로 래핑하며 SwiftUI에서 값 유형에 대한 영구 저장소를 할당하고 종속성을 만들기 때문에 상태의 변경이 뷰에 자동으로 반영됩니다. 뷰 내부에서 사용하도록 설계되었기 때문에 State를 선언할 때는 명시적으로 private 키워드를 사용하는 것이 가독성 측면에서 좋습니다.
관찰할 수 있는 객체를 인스턴스 화하는 속성 래퍼 유형입니다. StateObject 속성 래퍼를 사용하면 뷰에서 로컬 객체를 만들 수 있습니다. 이는 비동기 콜백 또는 시스템 전체 알림과 같은 이벤트를 수신하는 데 유용합니다. ObservedObject 프로토콜을 준수하면 객체에 포함된 속성의 변경 사항을 모니터링할 수 있습니다. SwiftUI는 객체를 선언하는 구조의 각 인스턴스에 대해 한 번만 인스턴스를 생성합니다. 관찰할 수 있는 객체의 속성이 변경되면 SwiftUI는 해당 속성에 의존하는 뷰 일부를 업데이트합니다.
관찰할 수 있는 객체를 구독하고 상태 객체가 변경될 때마다 뷰를 다시 렌더링하는 속성 래퍼 유형입니다. ObservedObject 속성 래퍼를 사용하면 뷰가 상위 항목 중 하나에서 종속성 주입을 통해 객체를 수신할 수 있습니다. 이 속성 래퍼는 @StateObject와 매우 유사합니다. 가장 중요한 차이점은 @StateObject가 원천 소스값을 생성하는 데 사용하는 반면, @ObservedObject는 이미 생성된 객체를 다른 뷰에 전달하는 수단이라는 것입니다. 따라서 @ObservedObject는 이미 생성된 객체를 추적하는 데 사용됩니다.
원천 소스의 값을 읽고 쓸 수 있는 속성 래퍼 유형입니다. Binding 속성 래퍼를 사용하면 뷰 계층 구조의 다른 위치에 있는 데이터를 변경할 수 있습니다. 이 속성 래퍼는 거의 모든 SwiftUI 뷰에서 유용합니다. 속성 변수에 $기호를 사용하여 바인딩 된 값을 가져오며 다른 뷰 계층 구조로 바인딩을 추가로 전달하고 변경할 수 있습니다. 상태가 변경되면 바인딩 된 모든 뷰에 즉시 반영됩니다. Binding은 단일 속성을 읽고 쓸 수 있는 속성 래퍼입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ContentView: View {
@State private var isOpen: Bool = false
var body: some View {
VStack {
Text(isOpen ? "O" : "X")
Divider()
Toggle(isOpen ? "Open" : "Close", isOn: $isOpen)
Divider()
Door(isOpen: $isOpen)
}
}
}
struct Door: View {
@Binding var isOpen: Bool
var body: some View {
Button("Toggle Door") {
isOpen.toggle()
}
}
}
부모 또는 상위 뷰에서 제공하는 관찰 가능한 객체에 대한 속성 래퍼 유형입니다. EnvironmentObject 속성 래퍼를 사용하면 모든 뷰에서 전역 공유 객체에 액세스할 수 있습니다. 따라서 모든 중간 뷰를 건너뛰고 깊이와 관계없이 주입하는 부모 뷰 계층 구조의 모든 하위 뷰에서 데이터를 전달받을 수 있습니다. 이 속성 래퍼는 객체 자체를 생성하거나 할당하지 않고 뷰 계층 구조의 환경을 모니터링하는 메커니즘을 제공합니다. EnvironmentObject 속성 래퍼는 UI 기능을 컴포넌트로 개발할 때 상태를 하위 뷰로 쉽게 전달할 수 있는 메커니즘을 제공하기 때문에 매우 유용합니다.
이 밖에도 아래와 같이 특정 작업에서 사용할 수 있는 속성 래퍼들도 있습니다.
기본값의 변경에 따라 뷰를 다시 렌더링하는 속성 래퍼 유형입니다. AppStorage 속성 래퍼는 모든 SwiftUI 뷰에서 사용자의 기본값에 접근하는데 편리합니다. 보통 설정이나 개인화 데이터에 접근할 때 사용합니다.
지속되는 Scene 별 저장소를 읽고 쓰는 속성 래퍼 유형입니다. SceneStorage 속성 래퍼를 사용하면 뷰가 지속되는 한 Scene 별 저장소에 액세스할 수 있습니다. 사용자가 마지막으로 떠난 위치에서 앱을 다시 시작할 수 있도록 UI 상태를 복원할 때 주로 사용합니다.
@UIApplicationDelegateAdaptor, @NSApplicationDelegateAdaptor
UIKit 앱 및 AppKit 앱 delegate를 만드는 데 사용하는 속성 래퍼 유형입니다. 이 속성 래퍼를 사용하면 UIKit/AppKit 앱 delegate를 SwiftUI 앱에 제공할 수 있습니다.
포커스가 있는 뷰 또는 상위 뷰 중 하나에서 값을 관찰하기 위한 속성 래퍼입니다. FocusedValue를 사용하면 현재 포커스가 있는 뷰의 상태에 액세스할 수 있습니다.
Core Data 영구 저장소에서 entity를 검색하는 속성 래퍼 유형입니다. FetchRequest 속성 래퍼를 사용하면 뷰에서 요청을 통해 Core Data에서 정보를 직접 검색할 수 있습니다. SwiftUI 환경에 저장된 전역 관리 객체 컨텍스트를 사용합니다.
사용자가 제스처를 수행하는 동안 속성을 업데이트하고 제스처가 종료되면 속성을 다시 초기 상태로 재설정하는 속성 래퍼 유형입니다. GestureState 속성 래퍼는 저장된 속성을 제스처 상태에 연결합니다. 제스처가 비활성화되면 속성값은 초기화됩니다.
숫자 값을 조정하는 동적 속성입니다. ScaleMetric 속성 래퍼는 장치의 다이나믹 폰트 설정에 따라 숫자 값의 크기를 자동으로 조정합니다 . UI를 다양한 텍스트 크기에 맞게 조정하는 데 사용합니다. 예를 들어 크기가 변경된 메트릭을 사용하여 프레임의 크기나 스택의 간격을 변경할 수 있습니다.
속성을 포함하는 뷰의 영구 ID로 정의된 네임스페이스에 대한 액세스를 허용하는 동적 속성 유형입니다. Namespace 속성 래퍼는 한 뷰에서 다른 뷰로 애니메이션을 동기화하는 데 사용됩니다. matchedGeometryEffect 수정자와 함께 사용합니다.
Radio Component의 사용성 목표
- Radio Item들은 그룹을 이루며 특정 Item을 선택하면 이전 Item은 선택 해제되어 단일 Item만 선택된 상태를 유지한다.
- 라디오 아이콘은 다른 뷰와 조합으로 구성할 수 있으며 Item들의 Layout은 자유롭게 구성할 수 있어야 한다.
- 선언적 구문이 아닌 프로그래밍 방식의 이벤트 수신도 가능해야 한다.
- 상태 값이 nil일 경우에는 선택된 Item이 존재하지 않아야 한다.
1번 목표는 Radio Component의 기본적인 기능에 대한 것입니다. A, B, C Item이 있다고 가정했을 때 A가 선택된 상태에서 B를 선택하면 B가 선택되고 A는 선택 해제되어야 하는 것이 Radio Component의 기본 기능입니다.
2번 목표는 Radio 아이콘 모양만 버튼으로 처리되는 것이 아닌 텍스트나 다른 뷰와 조합을 이뤄 하나의 버튼으로 기능해야 하며 이러한 조합의 그룹 또한 다양한 layout으로 구성할 수 있어야 한다는 것입니다.
https://dribbble.com/shots/19200713-Day-023-Radio-Buttons-100-days-UI-challenge
https://dribbble.com/shots/11239824-Radio-button-groups
3번 목표는 상태 값에 대한 바인딩 처리뿐만 아니라 프로그래밍 방식의 이벤트 수신도 가능하게 합니다. 가끔은 바인딩 된 값을 참조하는 것이 아닌 변경된 상태를 수신해서 특정 기능을 수행해야 할 경우도 발생합니다. 물론 onChanged를 통해서 변경된 상태를 수신할 수 있지만 여기서는 closure를 명시적으로 연결하여 이벤트를 수신하는 방법을 다룹니다. (가끔은 변경되기 전, 변경된 후의 데이터를 비교해야 할 때 프로그래밍 방식으로 처리하기도 합니다.)
4번 목표는 논란의 여지가 있는 옵션입니다. Radio Component는 기본적으로 default 값을 설정한 상태로 제공되는 것이 사용자에게 좀 더 명확한 의미 전달이 가능합니다. 하지만 이 글에서는 Optional이 있을 수 있다는 가정에서 nil 상태도 추가합니다.
그럼 이러한 목표를 기준으로 SwiftUI를 이용해서 Radio Component를 어떻게 설계할 수 있는지 알아보겠습니다. 일단 Component의 사용성에 부합하면서도 개발자가 사용하기 편한 사용 방법으로 접근해 보겠습니다.
1. RadioComponent 사용 방식
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
@State private var selection: Int? = 0
...
RadioComponent(selection: $selection) {
VStack {
HStack {
RadioItem(tag: 0)
Spacer()
Text("Item \(tag)")
}
.radioTag(0)
HStack {
RadioItem(tag: 1)
Spacer()
Text("Item \(tag)")
}
.radioTag(1)
}
}
위 코드는 최종적으로 개발자가 Radio Component를 사용할 때 구현하는 방식을 보여줍니다. RadioComponent를 컨테이너로 구성하고 컨테이너 안에 개별 Item 들을 다른 부와 함께 배치합니다. 배치되는 Item들의 Layout은 자유롭게 구성할 수 있습니다.
2. RadioValue 상태 객체 정의
RadioItem은 RadioComponent를 알 수 없으므로 상태를 공유하기 위해서는 RadioItem 뷰로 상태를 전달해야 합니다. 위에서 알아봤던 Binding 속성 래퍼를 통해서도 전달할 수 있으나 RadioItem에 전달하는 속성은 tag 하나로 단순화 시키는 것이 좋습니다. 따라서 RadioComponent에서는 하위 뷰에 상태를 공유하기 위해서 environmentObject 수정자를 이용하여 상태 객체(RadioValue)를 하위 뷰에 전달할 것입니다.
EnvironmentObject는 단일 소스가 아닌 객체를 전달할 수 있으므로 전달하는 상태 속성을 객체로 랩핑하는 작업이 필요합니다. 아래는 ObserableObject 프로토콜을 준수하는 RadioValue 입니다. 우리는 RadioComponent 객체를 생성할 때 이 객체를 StateObject 속성 래퍼로 래핑하여 하위 뷰로 전달합니다.
1
2
3
4
5
6
7
8
9
10
11
class RadioValue<T: Hashable>: ObservableObject {
typealias TapReceiveAction = (T?) -> Void
@Binding var selection: T?
var onTapReceive: (TapReceiveAction)?
init(selection: Binding<T?>, onTapReceive: (TapReceiveAction)? = nil) {
_selection = selection
self.onTapReceive = onTapReceive
}
}
3. RadioComponent 컨테이너 객체 정의
RadioValue를 초기화할 때 generic 상태 속성과 이벤트를 수신하기 위한 onTapReceive closure를 함께 전달하여 바인딩합니다. RadioValue는 environmentObject를 통해서 하위 뷰에 전달되기 때문에 RadioComponent로 래핑 된 모든 뷰에서 RadioValue 객체를 공유할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public struct RadioComponent<T: Hashable, Content: View>: View {
private let value: RadioValue<T>
private let content: () -> Content
public var body: some View {
content()
.environmentObject(value)
}
}
extension RadioComponent where T: Hashable, Content: View {
public init(selection: Binding<T?>, @ViewBuilder _ content: @escaping () -> Content) {
self.value = RadioValue(selection: selection)
self.content = content
}
public init(selection: Binding<T?>, @ViewBuilder _ content: @escaping () -> Content, onTapReceive: ((T?) -> Void)?) {
self.value = RadioValue(selection: selection, onTapReceive: onTapReceive)
self.content = content
}
}
4. RadioItem 뷰 객체 정의
이제 RadioItem 뷰 객체를 정의합니다. on/off 상태에 따라서 현재 RadioItem의 상태를 UI로 표현하기 위해 Item의 id에 해당하는 tag를 전달합니다. @EnvironmentObject 속성 래퍼로 RadioValue 객체를 참조할 수 있으므로 현재 tag와 RadioValue의 selection 속성을 비교하여 on/off 여부를 확인할 수 있습니다.
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
public struct RadioItem<T: Hashable>: View {
@EnvironmentObject private var value: RadioValue<T>
private var tag: T?
public var body: some View {
ZStack {
Circle()
.fill(Color.white)
Circle()
.stroke()
.fill(Color.black.opacity(0.2))
if tag == value.selection {
Circle()
.fill(Color.accentColor)
.frame(width: 8, height: 8)
.transition(.scale)
}
}
.frame(width: 16, height: 16)
.animation(.easeInOut(duration: 0.2), value: value.selection)
}
}
public extension RadioItem where T: Hashable {
init(tag: T) {
self.tag = tag
}
}
5. RadioItemModifier 뷰 수정자 객체 정의
마지막으로 RadioItem을 다른 뷰와 그룹으로 묶어서 하나의 아이템 버튼으로 기능할 수 있도록 수정자 객체를 정의합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public struct RadioItemModifier<T: Hashable>: ViewModifier {
@EnvironmentObject private var value: RadioValue<T>
private var tag: T?
public func body(content: Content) -> some View {
Button {
value.selection = tag
value.onTapReceive?(tag)
} label: {
content
}
.buttonStyle(.plain)
}
}
public extension RadioItemModifier where T: Hashable {
init(_ tag: T?) {
self.tag = tag
}
}
뷰의 기본 modifier를 통해서 주입할 수도 있지만 아래와 같이 View에 extension으로 처리하면 더욱 쉽게 수정자를 주입할 수 있습니다. 저는 개별 수정자의 View extension은 프로젝트에서 하나의 파일로 분류하지 않고 해당 수정자 객체 하단에 함께 정의합니다. 이렇게 하면 해당 수정자 파일에서 분리하는 것보다 코드 관리 차원에서 효율적입니다.
1
2
3
4
5
extension View {
public func radioTag<T: Hashable>(_ tag: T?) -> some View {
self.modifier(RadioItemModifier(tag))
}
}
이제 모든 객체는 정의가 완료되었습니다. 사용하고자 하는 화면에서 아래와 같이 RadioComponent 컨테이너로 래핑하여 Radio Component를 자유롭게 디자인하여 적용할 수 있습니다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
struct ContentView: View {
enum AlignmentType: String {
case horizontal
case vertical
}
enum LayoutType: String {
case front
case behind
}
@State private var alignment: AlignmentType = .vertical
@State private var layout: LayoutType = .front
@State private var selection: Int? = 0
@Namespace var namespace
var body: some View {
VStack {
if let selection {
Text("Value : \(selection)")
}
Divider().padding()
radioContainer()
.frame(height: 130)
Divider().padding()
buttons()
}
.animation(.easeInOut, value: alignment)
.animation(.easeInOut, value: layout)
.padding()
}
private func radioContainer() -> some View {
RadioComponent(selection: $selection) {
if alignment == .vertical {
VStack(spacing: 16) {
items()
}
} else {
HStack(spacing: 16) {
items()
}
}
} onTapReceive: { value in
print(value ?? "nil")
}
}
private func items() -> some View {
ForEach(0..<4) { index in
item(tag: index)
.matchedGeometryEffect(id: "\(index)", in: namespace)
}
}
private func item(tag: Int) -> some View {
HStack(spacing: 0) {
if layout == .front {
RadioItem(tag: tag)
.matchedGeometryEffect(id: "icon\(tag)", in: namespace)
Spacer()
Text("Item \(tag)")
.font(.callout)
.opacity(0.6)
.matchedGeometryEffect(id: "title\(tag)", in: namespace)
} else {
Text("Item \(tag)")
.font(.callout)
.opacity(0.6)
.matchedGeometryEffect(id: "title\(tag)", in: namespace)
Spacer()
RadioItem(tag: tag)
.matchedGeometryEffect(id: "icon\(tag)", in: namespace)
}
}
.contentShape(Rectangle())
.frame(width: 70)
.radioTag(tag)
}
private func buttons() -> some View {
VStack(spacing: 16) {
Button {
alignment = alignment == .vertical ? .horizontal : .vertical
} label: {
Text("Change Direction")
}
Button {
layout = layout == .front ? .behind : .front
} label: {
Text("Change the layout")
}
}
}
}
6. 마무리
이처럼 SwiftUI의 상태 관리 메커니즘은 다양한 방식으로 UI를 설계하고 구현할 수 있습니다. 개발자의 아이디어에 따라 사용하기 쉽고 단순한 구조로 원하는 기능을 구현할 수 있습니다.
위 예제 코드는 아래 경로에서 모두 제공합니다. Fabula 프로젝트에서는 SwiftUI를 통해서 다양한 UI 실험을 진행 중이며 함께할 개발자들을 기다리고 있습니다. 단순한 SwiftUI 학습 코드 1개라도 기여하시면 기여자 목록에 등록할 수 있으니 가벼운 마음으로 많은 분들이 참여하시면 좋겠습니다.
다음 편에서도 SwiftUI 개발자들에게 도움이 될 만한 아이템을 들고 찾아뵙겠습니다. 감사합니다.