ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [iOS/Swift] SwiftUI 계산기
    야매 iOS 2023. 8. 7. 07:57

    ViewBuilder

    @ViewBuilder는 Result Builder로 다른 뷰를 조합해서 복잡한 레이아웃을 생성할 수 있는 커스텀 뷰를 구현할 때 사용한다.

    Apple A custom parameter attribute that constructs view from closures

    Result Builder

    ViewBuilder는 내부적으로 Result Builder로 구현되어 있다. 그러므로 Result Builder를 이해하면 ViewBuilder의 내부 동작에 대해 이해하는 것이 용이할 거라는 생각이 든다.

    Definition

    표현식을 하나씩 거치면서 생성되는 값을 조합해서 새로운 값을 생성할 수 있게 한다.

    • 즉 함수의 표현식의 결과를 모아서 전체 결과를 반환한다.

    Detail

    ******buildBlock 메서드******

    @resultBuilder
    struct StringBuilder {
    
        static func buildBlock() -> String {
            return ""
        }
    
        static func buildBlock(_ parts: String...) -> String {
            parts.joined(separator: "\\n")
        }
    }

    모든 resultBuilder는 적어도 하나의 정적 메서드인 buildBlock을 제공해야 한다.

    • 어떤 데이터를 받아서 변환할 수 있도록

    여러 문자열을 입력으로 받고 String을 반환하는 함수나 클로저에 StringBuilder를 적용할 수 있다.

    // ex
    
    // 첫 번째 buildBlock
    @StringBuilder func createSentence() -> String { }
    print(createSentence)
    /*
    */
    
    // 두 번째 buildBlock
    @StringBuilder func createSentence() -> String {
        "this is line 1"
        "this is line 2"
        "this is line 3"
    }
    
    print(createSentence())
    /*
    "this is line 1"
    "this is line 2"
    "this is line 3"
    */
    
    struct Temp {
        @StringBuilder let a: () -> String
    
        init(@StringBuilder a: @escaping () -> string) {
            self.a = a
        }
    }
    
    let temp = Temp {
        "this is line 1"
        "this is line 2"
        "this is line 3"
    }
    
    print(temp.a())
    
    /*
        "this is line 1"
        "this is line 2"
        "this is line 3"
    */

    buildOptional / buildEither

    closure 또는 함수의 코드 블럭 안에 조건문이 들어갈 수 있다.

    • ex/ SwiftUI에서 즐겨찾기 되어 있으면 색이 꽉 찬 별, 즐겨찾기 되어 있지 않으면 빈 별

    조건문이 있을 때 내부적으로 이를 처리하는 메서드들이 buildOptional과 buildEither

    extension StringBuilder {
        static func buildOptional(_ component: String?) -> String {
            return component ?? ""
        }
    
        /*
         else if statement
         */
        static func buildEither(first component: String) -> String {
            return component
        }
    
        /*
         else statement
         */
        static func buildEither(second component: String) -> String {
            return component
        }
    }

    switch문과 if문 모두 사용 가능

    buildArray

    closure 또는 함수의 코드 블럭 안에 반복문이 들어갈 수 있다

    반복분 내부에서 생성되는 값을 모아서 처리한다.


    What is ViewBuilder?

    ViewBuilder는 resultBuilder로 코드의 재사용과 유지보수성을 높인다.

    여러 뷰에서 공통된 스타일 또는 구조의 화면을 사용하지만 화면 또는 용도에 따라 조금씩 다르다면 그 화면들을 모두 구현해야 함

    하지만 ViewBuilder를 사용해 공통된 화면은 유지한 채 각기 다른 부분만 동적으로 처리할 수 있다.

    View Builder and Builtin SwiftUI

    뷰를 구성할 때 자주 사용하는 VStack과 HStack도 내부적으로 @ViewBuilder를 사용하고 있다

    @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
    @frozen public struct HStack<Content> : View where Content : View {
        @inlinable public init(
            alignment: VerticalAlignment = .center, 
            spacing: CGFloat? = nil,
            @ViewBuilder content: () -> Content
        )
        public typealias Body = Never
    }

    인지하지 못했지만 ViewBuilder는 여러 곳에서 많이 사용되고 있다.

    Detail

    struct Header<Content: View>: View {
        let title: String
        let content: Content
    
        init(title: String, @ViewBuilder content: () -> Content) {
                self.title = title
                self.content = content()
        }
    
        var body: some View {
            VStack {
                Text(title)
                content
                Divider()
            }
        }
    }
    Header(title: "my") {
        Text("This is my Menu")
        Image(systemName: "")
    }
    
    Header(title: "food") {
        Text("This is food Menu")
    }
    
    Header(title: "pay") {
        HStack {
            Text("This is pay Menu")
            Image(systemName: "")
        }
    }

    위와 같이 기본 틀은 유지하면서 동적으로 변경되어야 하는 부분만 동적으로 처리할 수 있다.

    Limitation

    ViewBuilder는 내부적으로 10개의 뷰만 처리할 수 있다.

    Result Builder의 예시에선 String… 을 사용해 문자열의 수를 무제한으로 받을 수 있었다.

    하지만 View 프로토콜 내부에는 Associated Type이 존재하므로 variadic argument list를 사용할 수 없다.

    그래서 ViewBuilder는 내부적으로 10개의 view 인자를 받는 buildBlock을 구현했다.

    총 정리

    ViewBuilder는 resultBuilder로 구현되어 있다.

    → 그러므로 클로저 또는 함수에 내에 있는 여러 표현식(뷰)를 조합해서 하나의 결과(뷰 계층)을 구현한다.

    ViewBuilder로 뷰 또는 뷰 계층을 여러 화면에서 공통으로 사용하는 화면에 전달해 코드의 재사용성과 유지보수성을 높일 수 있다.


    Geometric Reader

    Definition

    Geometry Reader는 크기와 좌표 정보를 제공하는 Container View이다

    A container view that defines its content as function of its own size and coordinate space

    Difference Between other Container View

    VStack, HStack, ZStack

    요소에 따라 크기가 늘어나도 줄어든다

    요소들은 중앙 정렬이다.

    GeometricReader

    차지할 수 있는 최대한의 공간을 차지한다.

    요소들은 origin에 포지셔닝 된다.

    내부에 여러 요소가 존재하면 ZStack처럼 동작한다

    Feature

    GeometryReader를 사용했을 때의 가장 큰 장점 GeometryProxy를 사용해 크기와 위치 정보를 얻는다는 것

    GeometryReader { <#GeometryProxy#> in
        <#code#>
    }

    Geometry Proxy

    Geometry Reader의 컨테이너 정보를 갖는다

    • origin
    • size(height/width)
    • frame(좌표 시스템)

    Coordinate according to View

    기준을 어디에 두느냐에 따라 좌표 값이 달라질 수 있다

    • 자기 자신을 기준으로 두면 그 값은 항상 (0,0)
    • 상위 뷰를 기준으로 두면 상위 뷰와 떨어진 만큼이 좌표 값이 된다.

    그 기준은 local, global, named로 둘 수 있다.

    local

    현재 화면을 기준으로 한 좌표

    global

    전체 화면을 기준으로 한 좌표

    named

    지정한 화면을 기준으로 한 좌표

    struct ContentView: View {
        var body: some View {
            VStack {
                Rectangle()
                    .fill(.yellow)
                VStack {
                    Rectangle()
                        .fill(.teal)
                    GeometryReader { proxy in
                        let x = proxy.frame(in: .local).midX
                        let y = proxy.frame(in: .named("VStack").midY
                    }
                }
                .coordinateSpace(name: "VStack")
            }
        }
    }

    위의 예시에서 “VStack”은 가장 상단에 있는 화면이다.

    GeometryReader 클로저 안에 있는 proxy.frame(in: .named(”VStack”)은 “VStack”으로 지정된 화면의 좌표를 기준으로 한 좌표를 뜻한다.

    Positioning

    GeometryReader 내부에서 있는 화면들은 position 모디파이어를 사용해서 위치를 조작할 수 있다. Geometry Proxy의 frame를 사용해서 위치를 조정할 때 midX, maxX, minX 등을 사용하게 된다.

    • 이 값들은 어느 화면을 기준으로 좌표를 얻냐에 따라 값이 달라진다
    min = (x or y)
    mid = (x + width or y + height) / 2
    max = (x + width or y + height)

    총 정리

    Geometric Reader는 Container View이고 자기 자신뿐만 아닌 다른 화면의 좌표 공간을 기준으로 자신이 어디에 위치해있는지 알 수 있다.


    Sheet

    bottom sheet은 한 화면에서 여러 이유로 나타날 수 있다. 그리고 그 이유 따라 각기 다른 형태를 가질 수 있다.

    환율 계산기를 구현할 때는 기준이 되는 통화를 선택 받을 때와 대상이 되는 통화를 선택 받을 때 bottom sheet을 사용했었다.

    sheet 모디파이너는 주로 전체화면에 사용하게 된다.

    → sheet modifer에서 어떤 용도로 사용되는지 알아야 화면을 구성하고 어떤 동작을 처리할 지 지정할 수 있다

    이때 enum을 사용해 여러 케이스를 지정하고 modifier 내부에서 해당 케이스에 따라 화면을 구성하고 동작을 처리할 수 있다.

    struct CurrencyCalculatorView: View {
    
        enum ActiveSheet: Identifiable {
            case from
            case to
    
            var id: Int {
                hashValue
            }
        }
    
            @State var activeSheet: ActiveSheet?
    
            var body: some View {
            VStack {
                List {
                    Section {
                        HStack {
                            Text("From Currency")
                            Spacer()
                            Button {
                                activeSheet = .from
                            } label: {
                                HStack(alignment: .center, spacing: 8) {
                                    Text(viewModel.fromCurrency.code)
                                    Image(systemName: "arrowtriangle.down.fill")
                                        .font(.caption)
                                }
                            }
                            .tint(.gray)
                        }
    
                        HStack {
                            Text("To Currency")
                            Spacer()
                            Button {
                                activeSheet = .to
                            } label: {
                                HStack(alignment: .center, spacing: 8) {
                                    Text(viewModel.toCurrency.code)
                                    Image(systemName: "arrowtriangle.down.fill")
                                        .font(.caption)
                                }
                            }
                            .tint(.gray)
                        }
                    }
                }
            }
    
            .sheet(item: $activeSheet) { activeSheet in
                switch activeSheet {
                case .from:
                     Button {
                        self.activeSheet = nil
                     } label: {
                          Text("From")
                     }
                case .to:
                    Button {
                        self.activeSheet = nil
                     } label: {
                          Text("To")
                     }
                }
    
            }   
        }   
    }

    위 예시에서 sheet(item)을 사용해 값에 따라 어떤 화면을 사용할 것이고 어떻게 처리할 것인지 알 수 있다.


    Task

    task는 view가 나타기 전에 혹은 특정한 값이 변경 될 때 비동기 작업을 수행한다.

    struct ContentView {
        var body: some View {
            Text("Text")
                .task {
                    await ...
                }
        }
    }

    Background

    task modifier 이전에는 onAppear modifier에서 Task를 생성하고 onDisappear modifier에서 Task를 해제했어야 했다.

    하지만 task modifier로 인해 위와 같은 과정을 거치지 않아도 된다.

    → task modifier가 추가된 뷰의 수명과 같은 수명을 가지기 때문

    뷰가 사라지면 실행 중인 task도 더 이상 진행하지 않는다.

    Feature

    Priority

    Task 클로저가 어떤 우선순위로 동작할지 지정할 수 있다.

    • default 값은 .userInitiated
    func task(
        priority: TaskPriority = .userInitiated,
        _ action: @escaping () async -> Void
    ) -> some View

    ID

    ID로 지정된 값이 변경되면 그 변경사항을 감지해서 현재 진행중인 task를 멈추고 새로운 task를 시작한다

    ID는 Equatable 프로토콜을 준수해야 한다.

    • 값이 변경되었는지 확인하기 위해 이전 값과 현재 값을 비교하기 때문
    func task<T>(
        id value: T,
        priority: TaskPriority = .userInitiated,
        _ action: @escaping () async -> Void
    ) -> some View where T : Equatable

    AppStorage

    @AppStorage는 UserDefaults로부터 쉽게 값을 저장하고 읽을 수 있도록 하는 property wrapper.

    • @AppStorage는 키와 함께 사용된다.

    동작은 UserDefaults에 값을 저장하거나 읽는 @State 프로퍼티와 같다.

    • @AppStorage property wrapper로 가리키는 값이 변경되면 뷰를 다시 그린다.
    @State var emailAddress: String = "sample@email.com" {
        get {
            UserDefaults.standard.string(forKey: "emailAddress")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "emailAddress")
        }
    }

    limitation

    @AppStorage에서 사용할 수 있는 타입은

    • Bool, Int, Double, String, URL, Data

    이외의 타입을 AppStorage에 저장하기 위해선 RawRepresentable을 준수하는 타입이어야 한다.

    init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == Int
    init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == String
    • 타입이 RawRepresentable을 준수하고 Value.RawValue가 Int 또는 String일 때 사용 AppStorage(UserDefault)에 저장할 수 있다.

    RawRepresentable을 사용해 어떻게 AppStorage(UserDefault)에 저장할 수 있는지 알아보기 이전에 RawRepresentable이 무엇인지 알아보는 것이 좋을 것 같다.

    RawRepresentable

    RawRepresentable은 프로토콜로 어떤 타입이 연관된 rawValue로 전환되거나 연관된 rawValue가 어떤 타입으로 전환되는 프로토콜을 의미한다.

    주로 EnumOption Sets에서 자주 사용된다.

    타입이 init(rawValue: )? , var rawValue 을 지원한다면 그 타입은 RawRepresentable을 준수하는 타입이라고 유추할 수 있다.

    Enum

    enum Music: String {
        case jazz
        case hiphop
        case rock
        case funk
    }

    위의 예시에서의 enum은 명시적으로 RawRepresentable 프로토콜을 준수하지 않지만 내부적으로 RawRepresentable 프로토콜을 준수한다

    init(rawValue: String)? 를 사용해 rawValue로 타입을 생성할 수 있고 type의 rawValue 프로퍼티로 rawValue 값을 얻을 수 있다.

    RawReprsentable을 적용해야 할 때

    enum Music: String {
        case jazz(String)
        case hiphop(Int)
        case rock(String, Int)
        case funk(String
    }

    위와 같이 작성하면 컴파일러는 에러를 보여준다.

    왜냐하면 컴파일러가 associated value를 갖는 케이스에 대해 rawValue를 유추할 수 없기 때문

    그렇기에 직접 rawValue를 사용한 생성자와 프로퍼티를 정의해줘야한다.

    💡 RawRepresentable은 꼭 extension을 사용해야 한다
    그렇지 않으면 에러 발생

    enum Music {
        case jazz(String)
        case hiphop(Int)
        case rock(String, Int)
        case funk(String)
    }
    
    extension Music {
        typealias RawValue = String
    
        init?(rawValue: RawValue) {
            if rawValue == "jazz" {
                    self = .jazz(rawValue)
            } else if rawValue == "hiphop" {
                    self = .hiphop(rawValue.count)
            } else if rawValue == "rock" {
                    self = .rock(rawValue, rawValue.count)
            } else if rawValue == "funk" {
                    self = .func(rawValue)
            } else {
                    return nil
            }
        }
    
        var rawValue: RawValue {
            switch self {
            case .jazz(let name):
                    return "jazz name is \(name)"
            ...
            ...
    
            }
    
        }
    }

    위와 같이 작성하면 rawValue와 함께 associated value도 사용할 수 있다.

    RawRepresentable과 AppStorage

    AppStorage가 지원하지 않는 데이터 타입이어도 타입이 RawRepresentable 프로토콜을 준수하고 RawValue의 타입이 String 또는 Int이면 AppStorage에 저장할 수 있다.

    나는 문자열 배열을 AppStorage에 저장하고 불러왔어야 했다. 하지만 배열은 AppStorage가 지원하지 않으므로 RawRepresentable을 사용해서 문제를 해결했다.

    @AppStorage("favorites") var favorites: [String] = []
    
    extension Array: RawRepresentable where Element: Codable {
        public init?(rawValue: String) {
            guard let data = rawValue.data(using: .utf8),
                  let result = try? JSONDecoder().decode([Element].self, from: data)
            else {
                return nil
            }
            self = result
        }
    
        public var rawValue: String {
            guard let data = try? JSONEncoder().encode(self),
                  let result = String(data: data, encoding: .utf8)
            else {
                return "[]"
            }
            return result
        }
    }

    💡 RawValue의 타입이 String이라는 사실을 잊으면 안된다

    init?

    AppStorage에 저장되어 있는 값을 불러올 때 init?(rawValue: String)가 불린다.

    AppStorage에 저장되어 있는 문자열을 데이터의 형태로 변환하고 이를 JSONDecoder로 디코딩 해 실제 배열을 추출한다.

    rawValue

    AppStorage에 값을 저장할 때 rawValue가 사용된다.

    배열은 JSONEncder로 데이터 형태로 저장한 후 이를 String으로 변환한다.

    변환한 String을 AppStorage에 저장한다.

    '야매 iOS' 카테고리의 다른 글

    [Swift] Dispatch  (0) 2023.08.07
    [Swift] Copy on Write  (0) 2023.08.07
    [iOS] Certificate & Provisioning  (0) 2023.08.07
    [Swift] some & any  (0) 2023.08.07
    [Swift] Protocol Oriented Programming  (0) 2023.08.07
Designed by Tistory.