SwiftUI
고친 사람 github-actions[bot]
SwiftUI 는 iPhone 과 Mac 앱의 화면을 Swift 코드로 짜게 해 주는 Apple 의 도구입니다. 개발자는 화면이 어떤 모습이어야 하는지만 적습니다. 보여 줄 값이 바뀌면 SwiftUI 가 화면을 새로 맞춥니다.
쉽고 빠른 이해
SwiftUI 는 값을 받아 화면 모습을 돌려주는 코드로 Apple 기기 앱의 화면을 짜게 합니다. 이름을 받아 「안녕, 유미」 한 줄을 띄우는 화면 조각을 코드 몇 줄로 적어 두는 식입니다.
이게 없으면 값이 바뀔 때마다 화면 부품을 찾아 글자를 바꿔 넣는 코드를 손으로 짜야 합니다. 한 곳만 빠뜨려도 값과 화면이 서로 다른 말을 합니다.
어떻게 도는가:
- 화면 조각마다 「이 값이면 이렇게 생긴다」를 적습니다
- 화면이 기대는 값에
@State같은 표시를 붙여 SwiftUI 에 맡깁니다 - 그 값이 바뀌면 SwiftUI 가 화면 모습을 다시 얻어 달라진 곳만 고칩니다
대가도 있습니다. 화면을 언제 다시 그릴지 개발자가 정하지 못합니다. 새 기능은 새 운영체제에서만 돕니다. 그래서 옛 기기까지 지원하는 앱은 기능을 골라 씁니다. SwiftUI 로 안 되는 부품은 예전 도구로 짜서 섞습니다.
새로 짜는 Apple 기기 앱의 화면에 씁니다. Android 앱이나 웹 페이지의 화면에는 쓰지 않습니다.
상세
SwiftUI 는 Apple 기기에서 도는 앱의 UI(User Interface, 사용자 인터페이스)를 짜는 프레임워크입니다. UI 는 사용자가 보고 누르는 화면 전체를 말합니다. iOS 가 도는 iPhone 과 iPad, macOS 가 도는 Mac, 손목시계와 TV 앱까지 같은 방식으로 짭니다.
코드는 Swift 로 적습니다. Swift 는 Apple 이 자기 기기의 앱을 짜라고 내놓은 프로그래밍 언어입니다.
SwiftUI 는 앱과 함께 묶여 배포되지 않습니다. 기기의 운영체제 안에 들어 있습니다. 앱은 운영체제에 든 SwiftUI 를 불러 씁니다. 그래서 앱 설치 파일이 SwiftUI 때문에 커지지 않습니다. 대신 기기의 운영체제가 낡으면 새로 나온 SwiftUI 기능을 못 씁니다.
이 절은 SwiftUI 이전의 방식에서 출발합니다. 예전 방식과 견준 뒤, 화면 조각을 적는 법과 값이 바뀔 때 다시 그리는 방식을 봅니다. 이어서 화면 조각끼리 값을 나누는 법, 배치를 적는 법, 목록을 다루는 법을 봅니다. 끝으로 이 방식이 치르는 값과 이 도구를 고르는 경우를 봅니다.
UIKit 으로 짜던 화면
SwiftUI 이전의 iPhone 화면은 UIKit 으로 짰습니다. UIKit 에서는 화면에 놓이는 부품 하나하나가 객체입니다.
글자 칸은 UILabel 객체이고 버튼은 UIButton 객체입니다. 이 객체들은 화면이 떠 있는 동안 계속 살아 있습니다.
값이 바뀌면 코드가 그 객체를 찾아 직접 고칩니다. 장바구니 개수가 3 에서 4 가 되면, 개수를 적은 글자 칸의 글자를 4 로 바꾸는 줄을 개발자가 씁니다. 이렇게 무엇을 어떻게 바꾸라고 한 줄씩 시키는 방식을 명령형이라고 부릅니다.
명령형 화면에서는 값과 화면이 따로 삽니다. 개수를 보여 주는 곳이 다섯 군데면 다섯 곳을 모두 고쳐야 합니다. 한 곳만 빠뜨려도 장바구니에는 4 개가 들었는데 화면은 3 을 보입니다.
선언형으로 바꾼 것
SwiftUI 는 반대로 갑니다. 개발자는 개수가 n 일 때 화면이 어떻게 생기는지만 적습니다. 개수가 바뀐 뒤 화면을 고치는 일은 SwiftUI 가 맡습니다. 결과의 모양만 적고 거기까지 가는 절차는 도구에 맡기는 방식을 선언형이라고 부릅니다.
백엔드 개발자에게는 Thymeleaf 같은 템플릿 엔진이 가까운 예입니다. 템플릿은 모델 데이터를 받아 웹 페이지 한 장을 만들어 냅니다. 데이터가 바뀌면 이미 보낸 페이지를 고치지 않고 페이지를 새로 만듭니다. SwiftUI 의 화면 조각도 값을 받아 화면 모습을 내놓습니다.
두 방식을 나란히 놓으면 이렇습니다.
| UIKit | SwiftUI | |
|---|---|---|
| 화면 조각 | 계속 살아 있는 객체 | 필요할 때마다 새로 만드는 값 |
| 값이 바뀌면 | 코드가 객체를 찾아 고친다 | 화면 모습을 다시 얻는다 |
| 값과 화면을 맞추는 책임 | 개발자 | SwiftUI |
셋째 줄이 두 방식을 가르는 차이입니다. 한 곳을 빠뜨려 화면이 어긋나는 실수가 SwiftUI 에서는 잘 생기지 않습니다. 첫째 줄은 바로 아래 소절이 풉니다.
뷰는 구조체다
SwiftUI 에서는 화면 조각 하나를 뷰라고 부릅니다. 뷰는 View 라는 프로토콜을 따르는 타입입니다.
Swift 의 프로토콜은 Java 의 인터페이스와 같은 구실을 합니다. 어떤 속성과 메서드를 갖춰야 하는지 약속만 적어 둡니다.
View 프로토콜이 요구하는 것은 하나입니다. body 라는 속성에 이 뷰가 어떻게 생겼는지를 적는 것입니다.
아래는 이름을 받아 인사말 한 줄을 띄우는 뷰입니다. 주석은 이름으로 「유미」를 넘겼을 때 화면에 뜨는 글자입니다.
struct Greeting: View {
let name: String
var body: some View {
Text("안녕, \(name)") // 안녕, 유미
}
}
Text 는 글자 한 줄을 화면에 놓는 뷰입니다. SwiftUI 가 기본으로 내놓는 부품입니다. 버튼과 그림과 입력 칸도 이렇게 뷰로 들어 있습니다.
some View 는 「View 를 따르는 어떤 타입」이라는 뜻입니다. 실제 타입 이름을 안 적고 이렇게 쓰는 까닭은 그 이름이 길기 때문입니다.
SwiftUI 에서 뷰를 겹치면 바깥 뷰의 타입 이름이 안쪽 뷰의 타입을 모두 품습니다. Text 둘을 세로로 쌓는 VStack 이면 그 타입 이름 안에 Text 가 두 번 들어갑니다.
뷰를 겹칠수록 이름은 계속 길어집니다.
그 긴 이름을 적는 일은 컴파일러가 맡고, 개발자는 some View 라고만 씁니다. 반환 타입의 실제 이름을 감추는 이 방식이 불투명 타입입니다.
Greeting 은 class 가 아니라 struct 로 적었습니다. struct 는 Swift 의 구조체입니다. 넘기거나 대입할 때마다 복사되는 값 타입입니다.
뷰를 구조체로 두는 까닭은 뷰가 화면에 떠 있는 부품이 아니기 때문입니다. 뷰는 화면이 어떻게 생겨야 하는지를 적어 둔 데이터입니다. 데이터일 뿐이라 자주 새로 만들고 버려도 부담이 적습니다. 그렇게 만들고 버리는 값에는 클래스보다 구조체가 맞습니다.
실제 화면 부품은 SwiftUI 가 이 데이터를 보고 따로 만들어 관리합니다. 그래서 값이 바뀔 때마다 뷰를 새로 만들어도 화면 부품까지 새로 만들지는 않습니다.
상태와 다시 그리기
값이 바뀌면 화면도 따라 바뀌어야 하는 값을 상태라고 부릅니다. 좋아요 수나 입력 칸에 친 글자가 상태입니다.
뷰가 스스로 쥐는 상태는 @State 를 붙여 적습니다. @State 같은 표시가 프로퍼티 래퍼입니다.
속성 하나에 붙어서, 그 속성을 읽고 쓸 때 끼어드는 코드를 붙여 주는 Swift 문법입니다.
@State 가 필요한 까닭은 뷰가 자주 새로 만들어지기 때문입니다. 뷰 안에 평범한 속성으로 좋아요 수를 두면, 뷰가 새로 만들어질 때 그 수도 0 으로 돌아갑니다.
@State 를 붙인 값은 구조체 안에 살지 않습니다. SwiftUI 가 따로 맡아 둡니다. 뷰가 몇 번 새로 만들어져도 값이 남는 까닭입니다.
아래는 누를 때마다 숫자가 하나씩 오르는 좋아요 버튼입니다. 주석은 버튼 글자가 누를 때마다 바뀌는 모습입니다.
struct LikeButton: View {
@State private var likes = 0
var body: some View {
Button {
likes += 1
} label: {
Text("\(likes)") // 0 → 1 → 2
}
}
}
Button 은 중괄호로 싼 코드 덩이를 둘 받습니다. 첫 덩이는 눌렸을 때 돌 코드입니다. label: 뒤 덩이는 버튼에 보일 모습입니다.
이렇게 이름 없이 넘기는 코드 덩이를 Swift 에서는 클로저라고 부릅니다. Java 의 람다와 같은 구실을 합니다.
버튼을 누르면 likes 가 1 오릅니다. SwiftUI 는 likes 를 읽은 뷰가 LikeButton 이라는 것을 압니다. 그 뷰의 body 를 다시 불러 새 화면 모습을 얻습니다.
그리고 새 모습을 바로 전 모습과 견줘 달라진 곳만 실제 화면에 옮깁니다. 이 예에서는 버튼 글자 하나만 바뀝니다.
버튼 한 번이 화면까지 가는 길을 그리면 아래와 같습니다.
sequenceDiagram
participant 사용자
participant 뷰 as LikeButton
participant S as SwiftUI
participant 화면
사용자->>뷰: 버튼을 누른다
뷰->>S: 맡겨 둔 likes 를 1 올린다
S->>뷰: likes 를 읽은 body 를 다시 부른다
뷰-->>S: 새 화면 모습
Note over S: 전 모습과 견준다
S->>화면: 달라진 글자만 고친다
바인딩
상태는 한 뷰가 쥡니다. 그런데 그 값을 다른 뷰가 바꿔야 할 때가 있습니다. 알림을 켜고 끄는 스위치가 그렇습니다. 켜짐 여부는 설정 화면이 쥡니다. 스위치 뷰는 눌릴 때 그 값을 바꿔야 합니다.
값만 넘기면 스위치는 복사본을 받습니다. 뷰가 구조체라서 넘긴 값이 복사되기 때문입니다. 복사본을 바꿔도 설정 화면의 값은 안 바뀝니다.
바인딩(Binding)이 이 문제를 풉니다. 바인딩은 값 자체가 아니라 「그 값을 읽고 쓰는 통로」입니다.
받은 쪽이 바인딩으로 값을 바꾸면 원래 쥔 쪽의 값이 바뀝니다.
struct SettingsView: View {
@State private var alarmOn = false
var body: some View {
Toggle("알림", isOn: $alarmOn)
}
}
Toggle 은 켜고 끄는 스위치 뷰입니다. 상태 이름 앞에 $ 를 붙인 $alarmOn 은 그 상태의 바인딩입니다.
사용자가 스위치를 밀면 Toggle 이 바인딩으로 alarmOn 을 바꿉니다. 그러면 SettingsView 가 다시 그려집니다.
값을 쥔 곳은 여전히 한 곳입니다. 같은 값이 두 곳에 따로 살지 않으니 화면끼리 어긋날 일이 없습니다. 이렇게 값의 주인을 한 곳에 두는 원칙을 단일 진실 공급원이라고 부릅니다.
여러 화면이 함께 쓰는 데이터
@State 는 뷰 하나가 쥐는 작은 값에 씁니다. 로그인한 사용자나 장바구니처럼 여러 화면이 함께 쓰는 데이터는 클래스에 담습니다.
클래스는 복사되지 않고 참조로 넘어갑니다. 그래서 여러 뷰가 같은 객체 하나를 봅니다.
이 클래스에는 @Observable 을 붙입니다. 같은 @ 로 시작하지만 프로퍼티 래퍼가 아닙니다. 속성이 아니라 클래스에 붙는 다른 종류의 표시입니다.
Swift 는 이런 표시를 매크로라고 합니다. 컴파일할 때 붙은 곳에 코드를 덧붙여 주는 문법입니다.
@Observable 이 붙은 클래스는 어느 뷰가 자기의 어느 속성을 읽었는지 기록합니다.
속성이 바뀌면 그 속성을 읽은 뷰만 다시 그려집니다.
@Observable
class Cart {
var items: [Item] = []
}
[Item] 은 Item 을 담는 배열 타입입니다. 뷰는 이 객체를 넘겨받아 cart.items 를 읽기만 하면 됩니다. 장바구니에 물건이 들어가면 목록을 읽은 뷰가 알아서 다시 그려집니다.
배치와 모디파이어
뷰를 어떻게 늘어놓을지는 배치용 뷰가 정합니다. 안에 적은 뷰들을 받아 배치를 잡습니다.
| 뷰 | 안의 것을 놓는 방향 |
|---|---|
VStack |
위에서 아래로 쌓는다 |
HStack |
왼쪽에서 오른쪽으로 늘어놓는다 |
ZStack |
한곳에 겹쳐 놓는다 |
여백과 배경색, 글꼴 같은 꾸밈은 모디파이어로 붙입니다. 모디파이어는 뷰 뒤에 점으로 이어 적는 메서드입니다.
.padding() 은 여백을 두릅니다. .background() 는 배경을 깝니다.
모디파이어는 원래 뷰를 고치지 않습니다. 원래 뷰를 감싼 새 뷰를 돌려줍니다. 그래서 적는 순서가 곧 감싸는 순서입니다. 뒤에 적은 모디파이어가 앞의 결과를 바깥에서 감쌉니다.
Text("주문")
.padding()
.background(.yellow) // 여백까지 노랗다
여백을 먼저 두르고 그 결과 전체에 배경을 깔았습니다. 배경이 여백까지 덮습니다.
Text("주문")
.background(.yellow)
.padding() // 글자 뒤만 노랗다
이번에는 배경을 먼저 깔고 그 바깥에 여백을 둘렀습니다. 여백이 배경 밖에 있으므로 글자 뒤만 노랗습니다. 같은 두 줄이 순서만으로 다른 화면을 만듭니다.
목록
주문 목록처럼 같은 모양이 여러 줄 되풀이되는 화면은 List 로 짭니다. List 는 배열을 받아 항목마다 한 줄씩 뷰를 만듭니다.
List(orders) { order in
OrderRow(order: order)
}
{ order in ... } 는 주문 하나를 받아 그 줄의 뷰를 돌려주는 클로저입니다. OrderRow 는 주문 한 건을 그리도록 개발자가 짠 뷰입니다.
List 는 화면에 보이는 줄의 뷰만 그때그때 만듭니다. 주문이 천 건이어도 보이는 열 줄 남짓만 만듭니다.
List 에 넘기는 항목에는 저마다 겹치지 않는 식별자가 있어야 합니다. 주문 하나가 지워지면 SwiftUI 는 어느 줄이 사라졌는지 알아야 하기 때문입니다.
식별자가 있으면 그 줄만 지우고 나머지 줄은 손대지 않습니다. 항목 타입은 Identifiable 프로토콜을 따라 id 속성을 갖춥니다. 주문이면 주문 번호가 id 가 됩니다.
선언형 화면의 대가
UIKit 으로 짠 화면은 무엇이 언제 바뀌는지가 코드에 적혀 있습니다. SwiftUI 에서는 그 판단을 SwiftUI 가 합니다. 화면이 버벅일 때 원인이 코드만 읽어서는 잘 드러나지 않습니다. 재 봐야 보입니다.
Apple 기기 앱은 Apple 이 내놓는 개발 도구 모음 Xcode 로 짭니다. 코드 편집기와 빌드 도구가 여기 함께 들어 있습니다.
Xcode 에는 측정 도구 Instruments 가 딸려 있습니다. 어느 뷰의 body 가 몇 번 다시 불렸는지를 이 도구로 잽니다.
운영체제에 묶인 것도 대가입니다. SwiftUI 는 운영체제와 함께 버전이 올라갑니다.
앱이 옛 운영체제를 쓰는 기기까지 지원하면 새 기능을 쓸 때마다 if #available 로 운영체제 버전을 확인해야 합니다. 그리고 버전에 따라 코드를 갈라 짭니다.
SwiftUI 뷰로 나와 있지 않은 부품도 있습니다. 카메라 미리 보기 화면이 그런 예입니다. 그런 부품은 UIKit 으로 짜서 SwiftUI 화면에 넣습니다. 넣는 방법은 아래 「맞물림」 절에 있습니다.
SwiftUI 를 고르는 경우
새로 짜는 Apple 기기 앱은 SwiftUI 로 시작하는 일이 많습니다. 이미 UIKit 으로 짠 큰 앱은 한 번에 옮기지 않습니다. 새 화면부터 SwiftUI 로 짭니다. 한동안은 두 방식을 섞어 씁니다.
Android 앱이나 웹 페이지의 화면은 SwiftUI 로 짜지 않습니다. Android 에서 같은 선언형 방식을 쓰는 도구는 Jetpack Compose 입니다. 웹에는 React 가 있습니다. 한 코드로 iPhone 과 Android 를 함께 짜려면 Flutter 같은 크로스플랫폼 프레임워크를 봅니다.
맞물림
SwiftUI 는 혼자 앱을 이루지 않습니다. Apple 이 함께 내놓는 UIKit 과 Xcode, 그리고 바뀐 값을 신호로 흘려보내는 라이브러리 Combine 에 붙어서 돕니다. 이 절은 SwiftUI 를 쓰면 거의 반드시 만나는 네 가지 붙는 방식을 봅니다.
UIKit 화면이 SwiftUI 뷰를 품는다
UIKit 에서 화면 한 장은 뷰 컨트롤러가 맡습니다. 뷰 컨트롤러는 화면에 놓인 부품들을 관리하는 객체입니다. 화면이 뜨고 지는 때도 이 객체가 받습니다.
UIKit 앱에 SwiftUI 화면을 넣을 때는 UIHostingController 를 씁니다. SwiftUI 뷰를 넘겨 만들면 UIKit 이 보기에 평범한 뷰 컨트롤러가 됩니다.
UIKit 쪽은 이 컨트롤러를 다른 화면처럼 띄웁니다. 기존 UIKit 앱이 화면 하나씩 SwiftUI 로 옮겨 가는 길이 이것입니다.
대가는 경계에서 값을 옮기는 일입니다. UIKit 쪽 객체가 쥔 값이 바뀌어도 SwiftUI 는 모릅니다.
그 값을 @Observable 클래스처럼 SwiftUI 가 지켜보는 객체로 옮겨 주는 코드를 따로 짜야 합니다.
SwiftUI 화면이 UIKit 뷰를 품는다
거꾸로 SwiftUI 화면에 UIKit 부품을 넣을 때도 있습니다. 앞에서 본 카메라 미리 보기처럼 SwiftUI 뷰로 나와 있지 않은 부품을 쓸 때입니다.
그때는 UIViewRepresentable 프로토콜을 따르는 타입을 하나 짭니다.
이 타입은 메서드 둘을 갖춥니다. makeUIView 는 UIKit 부품을 처음 한 번 만듭니다. updateUIView 는 SwiftUI 쪽 상태가 바뀔 때마다 불립니다.
부르는 쪽은 SwiftUI 입니다. 그 안에서 UIKit 부품을 어떻게 고칠지는 개발자가 명령형으로 적습니다.
대가는 선언형 화면 한가운데에 명령형 코드가 한 조각 생긴다는 것입니다. UIKit 부품에서 일어난 일을 SwiftUI 로 올려 보내려면 Coordinator 라는 객체를 하나 더 짜서 중계를 맡깁니다.
Combine 이 SwiftUI 에 변경을 알린다
Combine 은 앞에서 말한 대로 바뀐 값을 신호로 흘려보내는 Apple 의 라이브러리입니다. @Observable 이 나오기 전에는 여러 화면이 함께 쓰는 데이터가 바뀐 것을 Combine 으로 알렸습니다. 지금도 이 방식으로 짠 앱이 많습니다.
데이터 쪽에서 할 일은 둘입니다. 데이터 클래스는 ObservableObject 프로토콜을 따릅니다. 바뀌면 알려야 하는 속성에는 @Published 를 붙입니다.
그러면 그 속성이 바뀔 때 객체가 「곧 바뀐다」는 신호를 흘려보냅니다.
SwiftUI 쪽은 뷰가 쓰는 객체마다 이 신호를 받겠다고 미리 걸어 둡니다. 이것을 구독이라고 합니다. 신호가 오면 SwiftUI 는 그 객체를 쓰는 뷰를 다시 그립니다.
대가는 신호가 객체 단위라는 것입니다. 속성 하나만 바뀌어도 그 객체를 쓰는 뷰가 전부 다시 그려집니다. 바뀐 속성을 읽지 않은 뷰도 그렇습니다.
속성 단위로 기록하는 @Observable 은 필요 없는 다시 그리기를 줄이려고 나왔습니다.
Xcode 가 SwiftUI 뷰를 미리 그린다
Xcode 는 앞에서 본 Apple 의 개발 도구 모음입니다. Xcode 는 앱을 띄우지 않고도 SwiftUI 뷰 하나를 편집기 옆에 그려 보여 줍니다. 이 기능을 미리 보기라고 부릅니다.
미리 보기는 코드에 #Preview 를 적어 켭니다. 그 안에 보여 줄 뷰와 넘길 값을 적습니다. 코드를 고치면 Xcode 가 그 뷰를 다시 빌드해 옆 그림을 바꿉니다.
대가는 두 가지입니다. 뷰가 서버나 앱 전체의 객체에 기대면 미리 보기에서 넘길 값이 없어 그리지 못합니다. 그래서 뷰가 필요한 값을 인자로 받도록 짜게 됩니다. 그리고 Xcode 는 macOS 에서만 돕니다. SwiftUI 앱을 짜려면 Mac 이 있어야 합니다.
관련 항목
SwiftUI 가 속하는 상위 분류
프레임워크 · UI 툴킷 · 선언형 프로그래밍 · 모바일 개발 · 사용자 인터페이스
SwiftUI 앱이 도는 운영체제
iOS · macOS · iPadOS · watchOS · tvOS
SwiftUI 를 짜고 빌드하는 언어와 도구
Swift · Xcode · Instruments · Swift Package Manager
SwiftUI 코드가 기대는 Swift 문법
구조체 · 값 타입 · 프로퍼티 래퍼 · 불투명 타입 · 클로저 · 매크로 · 프로토콜 지향 프로그래밍
SwiftUI 화면을 이루는 구성 요소
뷰 · UI 상태 · 바인딩 · 모디파이어 · 레이아웃
SwiftUI 화면 설계에 적용되는 원칙
단일 진실 공급원 · 단방향 데이터 흐름 · 옵저버 패턴 · 불변 객체
SwiftUI 와 맞물리는 Apple 프레임워크
UIKit · Combine · AppKit · SwiftData · Core Data
SwiftUI 가 대신하는 명령형 화면 부품
명령형 프로그래밍 · 뷰 컨트롤러 · 스토리보드 · Interface Builder · Auto Layout
SwiftUI 와 같은 선언형 방식의 UI 도구
Jetpack Compose · React · Flutter · React Native · Compose Multiplatform · 크로스플랫폼 프레임워크 · 템플릿 엔진
다른 이름: 스위프트UI · 스위프트 UI