SwiftUI

lyoodong·2023년 11월 18일

값 타입에 대한 정보 은닉화

  • 값 타입에 대한 정보를 숨기기 위해 2가지 옵션을 제공, Opaque Typesboxed protocol type

Opaque Types

  • 반환 되는 값(리턴 값)의 타입을 숨긴다.
  • ‘역제네릭’이라고 일반적으로 표현

타입 유출을 방지하면서 얻는 장점

GenericOpaque Types 모두 공통적으로 타입에 대한 유연성을 확보할 수 있다. 두 키워드 모두 컴파일 시점에 타입이 결정되지만, Generic의 경우 타입이 선언자 외부로 유출되는 반면 Opaque Types의 경우 타입이 선언자 외부로 유출되지 않는다.

즉, 이를 통해 좀 더 높은 추상화 수준을 확보할 수 있게 된다. 따라서 내부 구현의 변경이 발생해도 특정 프로토콜만 준수할 경우 그 어떠한 인터페이스에 대해서도 대응이 가능해진다.

Opaque Types과 boxed protocol type 의 차이

Opaque Types

  1. 타입 일관성 보장
  2. 구체적 타입 정보를 은닉
  3. 컴파일 시점에 타입 결정

boxed protocol type

  1. 타입 정보 일부 노출
  2. 타입 다양성
  3. 런타임 시점에 타입 결정

SwiftUI의 기본 View에 적용된 Opaque Types

스유에서 기본 뷰 구조

struct AboutModifier: View {
    var body: some View {
        Text("Hello")
    }    
}
  • 구조체가 View 프로토콜 채택
  • 내부 프로퍼티인 body에서 Opaque Types이 적용된 some View 채택
  • 따라서, 인터페이스 내부의 변경 사항에 따라 구체적인 타입은 물론 달라지지만 인터페이스 외부에서는 View 프로토콜을 채택한 추상화된 타입으로 인식
  • 이를 통해 ViewModifier를 통해 내부가 변경 되더라도 body에 대한 타입 일관성, 유연성을 제공

ViewModifier & modifier(_:)

func modifier<T>(_ modifier: T) -> ModifiedContent<Self, T>
  • Applies a modifier to a view and returns a new view.
  • modifier를 적용하면 새로운 View를 리턴한다.

View Protocol

associatedtype

  • Generic의 개념을 프로토콜에 적용시킨 것.

사용법

  1. 타입 정의
  2. 추가 타입 제약(필요 시 제약사항을 프로토콜로 추가할 수 있다. View, Equatable 등)
//SwiftUI의 View 프로토콜 내부
public protocol View {
		//연관 타입 Body 선언
    associatedtype Body : View
		//연관 타입 Body 타입인 body 계산 프로퍼티
    @ViewBuilder @MainActor var body: Self.Body { get }
}

View 제작

struct ContentView: View {
    var body: some View {

    }
}

빌드 시 에러 발생

  • Property declares an opaque return type, but has no initializer expression from which to infer an underlying type
  • Missing return in accessor expected to return 'some View’

발생 원인

  • View 프로토콜을 채택한 ContentView는 필수적으로 body 프로퍼티를 선언해야 한다.
    (상단 View Protocol 설명 참조)
  • 이때, bodysome View 타입이며, 이러한 타입을 리턴해야 한다.
  • 위의 사례 처럼, body 프로퍼티 내부가 비어 있거나, 반환 타입을 명확히 추론할 수 없는 경우, Swift 컴파일러는 "Property declares an opaque return type, but has no initializer expression from which to infer an underlying type"와 같은 컴파일 에러를 발생시킨다.
  • 이는 body가 구체적인 뷰 타입을 반환하도록 요구, 따라서 body의 내부에는 특정 View를 반드시 초기화해야 한다.

Property Wrapper

  • 직역하면 ‘프로퍼티를 감싸는 장치’로 해석 할 수 있다.

개념

  • Swift5.1에서 공개
  • 이미 정의된 property가 존재하면, 이를 감싸서 새로운 속성으로 반환

wrappedValue

  • Property Wrapper 내부에서 실제로 저장되는 값을 말함
  • Property Wrapper 내부의 연산 로직 등을 처리

wrappedValue

  • Property Wrapper 의 추가 정보나 기능을 제공하기 위해 사용
  • $ 키워드를 통해 접근

코드 예시

@propertyWrapper
struct DecimalValue {

    var defaultValue: String 
    var projectedValue = "아무거나"

    var wrappedValue: String {
        get {
            return defaultValue   
        }
        
        *set {*
            let result = Int(newValue)!.formatted(.number)
            defaultValue = result
            projectedValue = "이체할 금액은 \(result)입니다."
        }
    }
}

struct Example {
    @DecimalValue(defaultValue: "0원")
    var number
}

var example = Example()

example.number = "100000"
example.number // 1000,000원
example.$number // 이체할 금액은 1000,000원입니다.

Extract Subview

  • 재사용 되는 뷰를 서브뷰로 빼주는 기능
  • 서뷰뷰로 빼고 싶은 코드 블럭을 선택 후 우클릭 Extract Subview 옵션 선택
struct ContentView: View {
    var body: some View {
        //View 1
				VStack(spacing: 10) {
            Image(systemName: "star.fill")
                .background(.red)
            Text("토스증권")
                .background(.green)
        }
        .padding()
        .background(.yellow)
				//View 2
        VStack(spacing: 10) {
            Image(systemName: "star.fill")
                .background(.red)
            Text("토스증권")
                .background(.green)
        }
        .padding()
        .background(.yellow)
        //View 3
        VStack(spacing: 10) {
            Image(systemName: "star.fill")
                .background(.red)
            Text("토스증권")
                .background(.green)
        }
        .padding()
        .background(.yellow)
    }
}

//Extract Subview
struct ContentView: View {
    var body: some View {
        
        HStack {
            CardView(name: "토스 증권", imageName: "star")
            CardView(name: "토스 증권", imageName: "star")
            CardView(name: "토스 증권", imageName: "star")
        }
    }
} 

struct CardView: View {
    
    var name: String
    var imageName: String
    
    var body: some View {
        VStack(spacing: 10) {
            Image(systemName: imageName)
                .background(.red)
            Text(name)
                .background(.green)
        }
        .padding()
        .background(.yellow)
    }
}

Modifier 캡슐화

  • Modifier ****또한 뷰처럼 캡슐화할 수 있다.
  • ViewModifier 프로토콜을 채택한 구조체를 생성
  • 구조체 내부에 func body(content: Content) -> some View 구현
  • 이때, 인자인 content에 원하는 Modifier를 추가

예시코드

//파일 내부에서 private으로 설정 요망
private struct PointBorderText: ViewModifier {
    
    func body(content: Content) -> some View {
        content
            .font(.title)
            .padding(10)
            .foregroundColor(.white)
            .background(.purple)
            .clipShape(.capsule)
    }
}

extension View {
    func pointBorderText() -> some View {
        modifier(PointBorderText())
    }
}

struct CardView: View {
    
    var body: some View {
        VStack(spacing: 10) {
            Image(systemName: imageName)
                .background(.red)
            Text(name)
                .pointBorderText()                
        }
    }
}

SwiftUI Data Flow

  • 스유에서는 Source of Truth를 통해 데이터와 UI 간의 의존관계를 생성한다.
  • 이를 통해 데이터의 변화에 따라 body를 Redering한다.
  • 이를 그림으로 도식화하면 아래와 같다.

데이터 관리

  • 스유에서는 보통 @State, @Binding, @ObservedObject, @EnvironmentObject 등의 속성 래퍼를 사용하여 데이터 모델을 관리한다.
  • @State: 프로퍼티(데이터)에 대한 상태를 저장하고 관찰, private을 통해 해당 View에서만 유효하다는 것을 명시
  • @Binding: 상위 뷰가 가진 상태를 하위 뷰에서 사용하고 관찰한다. $라는 접두어를 통해 접근

코드 예시

  1. @State 를 통해 데이터 바인딩
struct TamagochiView: View {
    
    @State private var riceCnt: Int = 0
    @State private var waterCnt: Int = 0
    
    var body: some View {
        VStack {
            Text("밥알 갯수\(riceCnt)")
            Button("증가") { 
                riceCnt += 1
            }
        }
        
        VStack {
            Text("물 갯수\(waterCnt)")
            Button("증가") { 
                waterCnt += 1
            }
        }
    }
}
  • riceCnt와 waterCnt를 @State 프로퍼티 래퍼로 감싸준다.
  • 이를 통해, 두 프로퍼티의 변화에 따라 UI가 다시 렌더링된다.
  1. @Binding을 통해 하위뷰에 데이터 전달.
struct TamagochiView: View {
    
    @State private var riceCnt: Int = 0
    @State private var waterCnt: Int = 0
    
    var body: some View {
        VStack {
            CountView(cnt: $riceCnt, name: "밥알")
            CountView(cnt: $waterCnt, name: "물")
        }
    }
}

struct CountView: View {
    
    @Binding var cnt: Int
    let name: String
     
    var body: some View {
        VStack {
            Text("\(name) 갯수\(cnt)")
            Button("증가") {
                cnt += 1
            }
        }
    }
}

차이점

  • 소유권: @State는 뷰 내부에서 정의되고 관리되는 반면, @Binding은 외부에서 정의된 데이터에 대한 참조이다.
  • 데이터의 출처: @State는 해당 뷰의 로컬 상태를 나타내고, @Binding은 다른 상태나 속성에 바인딩되어 있다.

@State는 주로 뷰의 내부 상태를 관리하는데 사용되고, @Binding은 부모-자식 뷰 간의 데이터 공유 및 동기화에 사용된다.


화면 전환

  • SwiftUI에서 화면 전환 방식은 UIKit과 크게 다를 바 없다.
  • Push, Sheet, Full 방식 존재

Push

  • NavigationView를 활용해 구성
  • ViewNavigationView로 감싸준 다음, NavigationLink를 통해 화면 전환
struct PushTransitionView: View {
    var body: some View {
        NavigationView {
            NavigationLink("Push") { 
                ContentView() //이동할 View
            }
        }
    }
}

Sheet, Full

  • 화면 전환에 대한 트리거 역할을 할 속성 래퍼 선언
  • 원하는 시점에 트리거의 값 전환
  • .sheet, .fullScreenCover modifier를 통해 화면 전환
struct PullSheetTransitionView: View {
    
    @State private var isFull = false
    @State private var isSheet = false
    
    var body: some View {
        HStack {
            transitionButton("Full", trigger: $isFull)
                .fullScreenCover(isPresented: $isFull, content: {
                    ContentView()
                })
            
            transitionButton("Sheet", trigger: $isSheet)
                .sheet(isPresented: $isSheet, content: {
                    ContentView()
                })
        }
    }
}

func transitionButton(_ title: String, trigger: Binding<Bool>) -> some View {
    Button(title) { 
        trigger.wrappedValue.toggle()
    }
}
  • isPresented 파라미터의 타입은 Binding<Bool>이다.
  • Full같은 경우 화면전환 시, 전환 된 화면에서 뒤로가기 생성 후 @Binding을 통해 상태 값을 동기화 시켜준다. 즉, 뒤로가기 버튼을 클릭하면 isFull의 값을 False로 전환

**AsyncImage**

  • 비동기적으로 이미지를 로드하는 뷰
var body: some View {
    ScrollView {
        AsyncImage(url: url) { data in
            switch data {
            case .empty:
                ProgressView()
            case .success(let image):
                image
            case .failure(let error):
                Image(systemName: "star")
            @unknown default:
                Image(systemName: "star")
            }
            
        }
    }
}
  • 이미지 url을 할당한 후 여러 케이스에 대해 분기
  • 이미지 로딩이 성공하면, case .success(let image)에서 불러온 이미지를 설정할 수 있다.

  • NavigationLink는 초기화 시 내부의 뷰를 즉시 생성
  • 이는 특히 복잡하거나 리소스를 많이 소모하는 뷰를 다룰 때 성능 문제를 야기
  • 따라서, NavigationLazyView를 활용해 사용자가 링크에 접근 시 생성하도록 설정
struct NavigationLazyView<T: View>: View {
    
    let build: () -> T
    
    init(_ build: @autoclosure @escaping () -> T) {
        self.build = build
    }
    
    var body: some View {
        build()
    }
}
  • @autoclosure 중괄호 생략 키워드

Dismiss

//Full된 뷰
@Environment(\.presentationMode) **var** presentationMode
.
.
.
presentationMode.wrappedValue.dismiss() //dismiss()
  • @Environment는 앱 전체에 사용되는 환경 변수의 역할
  • 시스템에서 정의된 값을 감지하고 뷰를 업데이트 할 수 있다.
  • 애플에서 제공되는 기본 값은 .presentationMode, .colorScheme등이 있고 커스텀도 가능함.
    //다크, 라이트 모드 대응
    struct ContentView: View {
      @Environment(\.colorScheme) var colorScheme // <-
      
      var body: some View {
        Text("Hello, world!")
          .padding()
          .foregroundColor(colorScheme == .dark ? .black : .white)
      }
    }

  • iOS 16 +
  • NavigationDestionation과 궁합이 좋다.
  • 이전, NavigationView에서는 사용자가 직접 데이터 스트림에 대한 반응을 분기처리 했다.
  • 하지만, NavigationStack은 NavigationDestionation을 설정함으로서 데이터 좀 더 동적으로 대응할 수 있다.
//NavigationView
var body: some View {
        NavigationView { // This is deprecated.
            List {
                NavigationLink("Purple") {
                    ColorDetail(color: .purple)
                }
                NavigationLink("Pink") {
                    ColorDetail(color: .pink)
                }
                NavigationLink("Orange") {
                    ColorDetail(color: .orange)
                }
            }
        }
        .navigationViewStyle(.stack)
    }

//NavigationStack
var body: some View {
            NavigationStack {
                List {
                    NavigationLink("Mint", value: Color.mint)
                    NavigationLink("Pink", value: Color.pink)
                    NavigationLink("Teal", value: Color.teal)
                }
                .navigationDestination(for: Color.self) { color inColorDetail(color: color)
                }
                .navigationTitle("Colors")
            }
    }

0개의 댓글