본문 바로가기

iOS 프로그래밍/swiftUI

swiftUI 기본 요약

지난 6월 wwdc를 보셨나요? 이제 저희도 위젯을 사용할 수 있게 되었습니다.

따라서 8월 저희 앱이 급한 1차 개발이 완료 되었고 2차 개발을 하는 도중

위젯을 만들고 싶다는 저의 의견에 회사에서 알아서 만들어 보라고 하였고

위젯을 만들기 위해서는 swiftUI라는 언어가 필수적이었습니다.

그때 공부했던 기초들을 늦게나마 정리해 보려고 합니다.

 

(잡담: 블로그 포스팅 중에 그림으로 설명을 하고 싶을 때가 있는데 손그림 그려서 사진찍고 올려 왔었다.

그래서 아이패드 사고 싶다고 했었는데 드디어 아이패드를 구매해서 아이패드도 활용하는 포스팅을...ㅎㅎ)

 

목차를 보려면 더보기를 누르세요

더보기

목차

1. swiftUI란?

2. swiftUI의 특성

3. ContentView 살펴보기

4. SwiftUI 시작하기

5. 프리뷰

6. SwiftUI 문법

 

👩🏻‍💻 swiftUI란?

swift언어를 기반으로 새롭게 구성한 프레임워크로서, 최대한 장점을 살리면서 AppKit, UIKit처럼 구분하지 않고 유저 인터페이스 영역까지 swiftUI 하나로 모든 플랫폼에서 사용할 수 있도록 만든 것

 

💃🏻 swiftUI의 특성

  • 구조체인 struct로 만들어짐
  • 제네릭을 적극적으로 사용
  • 디자인 도구 - 기존에는 코드로 개발하느냐 스토리보드로 개발하느냐 선택을 해야했지만 이제는 간단한 코드로 UI를 그릴 수 있으며, 프리뷰를 통해 실행하지 않고 바로 바로 뷰로 확인 할 수 있다
  • 모든 애플 플랫폼 지원
  • 선언형
// 기존의 명령형 코드
let button = UIButton(type: .system)
button.setTitle("SwiftUI", for: .normal)
button.setTitleColor(.black, for: .normal)
button.titleLabel?.font = .preferredFont(forTextStyle: .title1)
button.addTarget(self, action: #selector(buttonDidTap(_:)), for: .touchUpInside)
view.addSubview(button)

button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])

@objc func buttonDidTap(_ sender: UIButton) {
print("hello, swiftUI!")
}

이렇게 긴 코드를

import SwiftUI

Button(action: {
    print("hello")
}) {
    Text("SwiftUI")
        .font(.title)
        .foregroundColor(.black)
}

 

이렇게 선언함으로 알아서 화면에 보여줄 지는 프레임워크가 알아서 해주게 됩니다.

명령형 프로그래밍처럼 변화하는 상태에 따라 수행되는 흐름의 여러 복잡성을 떠안는 대신 불변(immutable) 값을 사용하는 것으로 로직을 단순화시켜 줍니다.

 

🔍ContentView 살펴보기

struct Content: View {
    var body: some View {
        Text("Hello, World!")
    }
}

View타입은 기존의 UIView와 달리 프로토콜로 선언되어 있습니다.

 

하지만 여기서 body 프로퍼티에서 반환해야 하는 타입이 또 뷰 프로토콜을 준수하는 타입이어야하는 구조가 보입니다.

이렇게 무한 재귀호출이 일어나는거 아니야?

그래서 swiftUI에는 Text, Image, Color와 같이 실제 콘텐츠를 표현하는 기본 뷰와 Stack과 같은 컨테이너 뷰에는 더는 재귀호출이 일어나지 않게 다음과 같이 Never 타입이 사용됩니다.

extension Text : View {
    public typealias Body = Never
}

 

쉬어가는 tip! -> 프리뷰에 반영하는 Resume의 단축키는 Option + Command + P
                          library버튼 (+ 버튼) 단축키 shift + command + L

control  + option 누르고 클릭 (프리뷰에서도 동일)

 

🎉 SwiftUI 시작하기

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        Text("Hello SwiftUI🙋🏻‍♀️")
    }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())
// UIHostingController로 안 감싸면 오류
// SwiftUI의 뷰를 UIKit 프레임워크와 통합하려면 UIHostingController라는 중간 매개체가 필요하기 때문
// PlaygroundPage.current.setLiveView(ContentView()) 이렇게로도 쓸 수 있음

 

순서의 중요도

VStack {
  Text("SwiftUI")
    .font(.title)   // Text
    .bold()         // Text
    .padding()      // View
  Text("SwiftUI")
    .bold()         // Text
    .padding()      // View
    .font(.title)   // View
  Text("SwiftUI")
    .padding()      // View
    .font(.title)   // View
    .bold()         // 컴파일 오류
}

-> 출력 UI는 위에 두개 다 같음 하지만 반환 타입이 달라 조심해야함

Image("apple") // Image
  .resizable()
  .frame(width: 50, height: 50) // View
Image("apple")
  .frame(width: 50, height: 50)
  .resizable() // 이미지에서만 사용 가능한 수식어로 오류

이와 같이 순서에 따라 적용 범위가 달라질 수 있음

참고 : VStack으로 감싸는 이유는 body는 하나만 리턴 가능하기 때문
참고 : SwiftUI 의 수식어는 뷰를 직접 변경하는 것이 아니라 원래의 뷰를 수식하는 새로운 뷰를 반환합니다.
          Text("Hello").frame(width: 200)을 하면 Text 타입에서 ModifiedContent<Text, _FrameLayout> 타입으로 바뀝니다.
          액자에 콘텐츠를 넣은 형식이라고 이해하면 쉽습니다.

 

List - UITableView가 List가 되면서 달라진 점

엄청 긴 테이블 뷰 코드에서 아래와 같은 코드로 바뀌었는데요

기존에 몇개의 열인지 다른 함수에서 지정하고 뭐가 들어가야하는지도 다른 함수에 정의해야했는데

특히 저는 indexPath가 없어진것이 신기 했습니다.

동적인 콘텐츠에서 IndexPath가 없으면 어떻게 나열할 까요?

1. Range<Int>

List(0..<100) { // half open range operator의 범위 연산자만 가능 / 0...100 or 0... 오류
	Text("\($0)")
}

 

2. RandomAccessCollection

RandomAccessCollection 프로토콜을 준수하는 데이터를 제공하는 것으로

 

① id 식별자 지정

let Alpabets = ["A", "B", "C", "D"]
// 데이터 타입 자체가 hashable을 준수한다면 self라고 입력 가능
List(Alpabets, id: \.self) { alpabet in
	Text(alpabet)


let numbers = [1,2,3,4,5]
List(numbers, id: \.hashValue) { number in
	Text("\(number)")
}

 

② identifiable 프로토콜 채택

struct Animal: Identifiable {
	let id = UUID // id의 타입은 UUID 외에도 hashable을 준수하는 모든 타입 사용 가능
}
List([Animal(name: "Tory"), Animal(name: "Lilly")]) { ... } // id 생략

 

GeometryReader

: 자식 뷰에 부모 뷰와 기기에 대한 크기 및 좌표계 정보를 전달하는 기능을 수행하는 컨테이너 뷰 (회전되어도 값이 자동 갱신)

지오메트리 리더의 핵심인 지오메트리 프록시는 레이아웃 정보를 자식 뷰에 제공할 수 있습니다.

구분 설명
size GeometryReader의 크기를 반환
safeAreaInsets GeometryReader가 사용된 환경에서의 안전 영역에 대한 크기를 반환
frame 특정 좌표계를 기준으로 한 프레임 정보를 제공
subscript(anchor:) 자식 뷰에서 anchorPreference 수식어를 이용해 제공한 좌표나 프레임을 GeometryReader 좌표계를 기준으로 다시 변환하여 사용하는 첨자입니다.
이때 Anchor의 제네릭 매개 변수에는 CGRect 또는 CGPoint 타입 두 가지를 사용할 수 있습니다.

- frame : 

public enum CoordinateSpace {
    case global			// 화면 전체 영역을 기준으로 한 좌표 정보
    case local			// GeometryReader의 bounds를 기준으로 한 좌표 정보
    case named(AnyHashable)	// 명시적으로 이름을 할당한 공간을 기준으로 한 좌표 정보
}
var body: some View {
  HStack {
    Rectangle().fill(Color.yellow)
      .frame(width: 30)
    
    VStack {
      Rectangle().fill(Color.blue)
        .frame(height: 200)
      
      GeometryReader {
      // 레이아웃을 위한 뷰와 실제 콘텐츠 분리
        self.contents(geometry: $0)
          .position(x: $0.size.width / 2, y: $0.size.height / 2)
      }
      .background(Color.green)
      .border(Color.red, width: 4)
    }
    .coordinateSpace(name: "VStackCS") // VStack의 좌표 공간에 이름 부여
  }
  .coordinateSpace(name: "HStackCS")
}

func contents(geometry g: GeometryProxy) -> some View {
  VStack {
    Text("Local").bold()
    Text(stringFormat(for: g.frame(in: .local).origin)).padding(.bottom)
    
    Text("Global").bold()
    Text(stringFormat(for: g.frame(in: .global).origin)).padding(.bottom)
    
    Text("Named VStackCS").bold()
    Text(stringFormat(for: g.frame(in: .named("VStackCS")).origin))
      .padding(.bottom)
    
    Text("Named HStackCS").bold()
    Text(stringFormat(for: g.frame(in: .named("HStackCS")).origin))
  }
}

func stringFormat(for point: CGPoint) -> String { // 좌표 출력
  String(format: "(x: %.f, y: %.f)", arguments: [point.x, point.y])
}

 

📱프리뷰

기기 지정하기

struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
    	ContentView()
        	. previewDevice(PreviewDevice(rawValue: "iPhone 11 Pro")) // 기기 하나 지정
    }
}

또는 기기를 여러개 테스트 하고 싶을 수도 있다

아래는 노트 있는 것과 없는것을 테스트 하고 싶을 때의 예시이다

// 기기 여러개 테스트 1
Group {
	ContentView().previewDevice(PreviewDeivce(rawValue: "iPhone 11 Pro"))
    ContentView().previewDevice(PreviewDeivce(rawValue: "iPhone 8"))
}

// 기기 여러개 테스트2
// 위와 같은 방법은 설정이 각각 다를때는 유용할 수 있지만 현재 예시처럼 설정이 같으면 forEach를 이용하는 것이 편리
ForEach(["iPhone 11 Pro", "iPhone 8"], id: \.self) {
	ContentView().previewDevice(PreviewDeivce(rawValue: $0)
    	.previewDisplayName($0) // 이 소스는 없어도 가능하지만 컨테이너마다 헷갈리지 않도록 이름 지정하는 경우
}

 

💡 SwiftUI 문법

함수빌더

함수 빌더(Function Builders) swift에서 내장 도메인 특화 언어(DSL)를 정의하도록 추가된 문법입니다.

 

VStack의 생성자를 보면 content 매개변수 앞에 표기된 @ViewBuilder가 보입니다.

init(
    ...
   @ViewBuilder content: () -> Content
)
@ 기호가 접두어로 붙은 것은 속성(Attribute)이라고 표현합니다.
속성 : ①선언 속성 ②타입 속성 ③스위치 케이스 속성
이중 선언 속성은 타입, 메서드, 프로퍼티 등을 선언하는 경우에만 사용할 수 있는 속성을 선언 속성이라고 합니다.
ex) @available, @objc, @IBOutlet

 

@ViewBuilder

함수로 정의된 매개 변수에 뷰를 전달받아 하나 이상의 자식 뷰를 만들어내는 기능을 수행

뷰 빌더는 bulidBlock이라는 타입 매서드에 값을 전달하고 2개 이상의 뷰일 때는 TupleView라는 타입을 반환( 매개 변수 최대 10개 )

static func buildBlock<C0, C1>(C0, C1) -> TupleView<(C0, C1)>

 

@State

state는 읽고 쓰는 값을 관리해주는 property wrapper입니다. 아래 예제로 좀 더 쉽게 알아볼까요?

struct ContentView: View { // 버튼 누르면 버튼 이름이 UIKit에서 SwiftUI라고 바뀌는 예제
  var framework: String = "UIKit"
  var body: some View {
    Button(framework) {
      self.framework = "SwiftUI" // 오류 (body내에서 해당 프로퍼티 값을 수정하는 것은 불가능)
    }
  }
}

 

var body: Self.Body { get }

위의 예제에서 framework는 let 이 아닌 var 로 선언한 프로퍼티이지만 body가 mutating get이 아닌 get으로 선언되어 있기 때문에 뷰에서 어떤 상태를 저장하고 수정하는 방법을 사용해야합니다.

struct ContentView: View { // 버튼 누르면 버튼 이름이 UIKit에서 SwiftUI라고 바뀌는 예제
  @State private var framework: String = "UIKit" // 이렇게 써야 정상 작동
  var body: some View {
    Button(framework) {
      self.framework = "SwiftUI"
    }
  }
}

이렇게 읽고 쓰는 값을 관리해주는 State가 선언된 프로퍼티는 항상 초깃값을 그대로 유지하고 있을 뿐 변경이 발생하더라도 직접 값을 바꾸는 대신, SwiftUI에서 제공하는 저장소에 그 값을 전달하고 참조하는 형태로 동작하기 때문에 우리의 기대와 같이 값이 잘 변경
( View의 body에서만 state 프로퍼티에 접근 할 수 있도록 해야함 -> private으로 선언 )

@State 프로퍼티 래퍼 저장 방식

이 저장소는 힙(heap)에 할당이됩니다.

@Binding

struct MainView: View {
    @State private var isFavorite: Bool = true
}

struct DetailView: View {
    @Binding var isFavorite: Bool
}

연산 프로퍼티의 형태로 사용되어 그 자신이 직접 값을 보유하는 대신, 값을 읽고 수정하여 다른 뷰에 갱신된 데이터를 전달하는 역할

 

이렇게 보시면 이해가 안가시는게 당연합니다.

우선 swiftUI 의 구조에 대해서 알아보고

@State @Binding이 왜 어떻게 쓰이는지 알아보겠습니다.

 

원래 기존의 swift

// count 값이 변경되면 뷰에 변경 정보를 변경
var count = 0 {
    didSet { countLabel.text = "\(count)"}
}

// 새로운 데이터가 추가되면 UITableView 갱신
func appendData() {
    data.append("New Data")
    tableView.reloadData()
}

이와 같이 데이터가 추가되거나 변경되면, 변경사항을 뷰에 반영하려고 이런 추가 코드를 작성해야했습니다.

하지만 이렇게 데이터에 대한 의존성 정의를 수작업으로 하는 것은 코드가 복잡해질뿐 아니라, 실수할 가능성이 내포되어 있습니다.

 

SwiftUI에서는

① 어떤 동작이 발생

② 프레임워크에 의해 동작이 수행

③ 변경된 상태를 감지해 해당 상태에 의존하고 있는 뷰를 갱신

하는 형태로 진행되게 되었습니다.

이때, body 프로퍼티를 다시 호출하게 되지만, 모든 것을 다시 그리는 것이 아니라

뷰 계층 구조를 따라 내려가면서 @State를 소유한 뷰를 비교하고

유효성 검사하여 변경된 부분만 다시 렌더링 하기 때문에 매우 효과적으로 작업을 수행할 수 있습니다.

 

예를 들어, 

이렇게 페이스북에서 좋아요(isFavorite) 데이터는 메인화면(원천자료)와 상세화면(파생자료)의 값이 같아야합니다.

그런데 메인 화면과 상세 화면 두 뷰가 각각 개별적인 값을 소유하고 있다면 불일치가 발생할 수 있습니다. 

따라서 뷰가 참조하는 데이터는 단일 원천 자료여야 합니다. (동일한 데이터 요소가 여러 곳으로 나뉘어 중복되지 않고 한 곳에서 다루어지고 수정되어야함)

따라서 위의 예제와 같이

struct MainView: View {
    @State private var isFavorite: Bool = true
}

struct DetailView: View {
    @Binding var isFavorite: Bool
}

@State는 뷰 자체에서 가져야 할 상태 프로퍼티이자 원천 자료로, 어떤 데이터에 대한 상태를 저장하고 관찰하는 역활을 수행합니다.

@Binding은 상위 뷰가 가진 상태를 하위 뷰에서 사용하고 수정할 수 있게 해주는 파생 자료에 사용 합니다.

 

이러한 형태는 토글이나 스테퍼에서 사용되고 있습니다.

struct ContentView: View {
    @State private var isFavorite = true
    @State private var count = 0
    
    var body: some View {
        VStack(spacing: 30) {
            Toggle(isOn: $isFavorite) {
                Text("isFavorite : \(isFavorite.description)")
            }
            Stepper("Count: \(count)", value: $count)
        }
    }
}
public struct Toggle<Label> : View where Label : View {
    public init(isOn: Binding<Bool>, @ViewBuilder label: () -> Label)
  • isToggleOn -> Bool값
  • $isToggleOn -> Binding<Bool>

ObservableObject와 @ObservedObject

@State는 뷰 자신이 상태값을 가지는 데 반해,
@ObservedObject는 뷰 외부의 모델에 의존성을 가지고 그 데이터의 변화를 감지하기 위해 사용

즉, @ObservedObject는 여러 뷰나 ObservableObject 프로토콜을 준수하는 프로퍼티 혹은 메소드에서 공유가 가능하다.

class User: ObservableObject {
    let name = "User Name"
    var score = 0
}

struct ContentView: View {
    @ObservedObject var user: User
    
    var body: some View {
        VStack(spacing: 30) {
            Text(user.name)
            
            Button(action: { self.user.score += 1 }) {
                Text(user.score.description)
            }
        }
    }
}

하지만 값이 바뀌면 자동으로 뷰를 갱신하던 State와 달리 이것은 작동을 하지 않는다.

외부의 데이터이기 때문에 어떤 변경 사항을 어느 시점에 뷰에 전달할건지 알려주어야 한다.

이것을 가능하게 하는 것이 @published라는 property Wrapper이다

 

@Published / objectWillChange

위의 예제가 동작하려면

@Published var score = 0

또는

let objectWillChange = ObjectWillChangePublisher()
var score = 0 {
	willSet { obectWillChange.send() }
}

이렇게 해주어야 합니다.

published 는 변경 시점에 즉시 알리는 것이고

objectWillChange는 자신이 시점을 정하여 알리는 것입니다.

하지만 결국 @Published는 ObjectWillChangePublisher가 send 메서드를 호출하는 코드를 간소화한것 입니다.

 

@EnvironmentObject

@ObservedObject는 뷰의 서브 트리에서 해당 모델을 사용하지 않는 뷰가 있더라도

그 하위의 View가 모델을 사용하려면 꼭 전달받아 넘겨줄 책임이 따랐습니다.

 

하지만 EnvironmentObject는 부모 뷰가 어떤 값을 가진다면 그 자식 뷰들은 직접 전달받지 않더라도 어떤 뷰든지 부모 뷰와 동일한 데이터에 접근할 수 있습니다.

struct ContentView: View {
    @ObservedObject var user: User
    
    var body: some View {
        SuperView().environmentObject(User()) // 환격 객체 주입
    }
}

struct SuperView: View {
    var body: some View { Subview() }
}

struct Subview: View {
    @EnvironmentObject var user: User
    // Superview에서 subview에 값을 전달하지 않았는데 user 프로퍼티를 선언하고 사용
    var body: some View { Text(user.name.description) }
}

 

 

 

참고 문헌: 스윗한 SwiftUI 서적

'iOS 프로그래밍 > swiftUI' 카테고리의 다른 글

SwiftUI ( Animation )  (0) 2021.02.10