UIkit을 SwiftUI처럼 쓸수있는 방법이있다?(feat.@resultBuilder)

Youth·2024년 1월 22일
0

TIL

목록 보기
15/21

안녕하세요 킴스캐슬입니다
오늘은 UI를 그릴 때 사용할수있는 라이브러리를 한번 가져와봤습니다

제가 UI를 그릴때 라이브러리를 사용하는것에 대해서 망설이는 부분이있었는데요
만약에 라이브러리를썼을때 2시간이걸릴작업이 30분이걸린다고하면 그리고 그 작업이 4~5번정도 필요하다고하면 적용해보고 사용해보는것도 큰 경험과 도움이 된다고 생각했습니다

물론 어떻게 해당 라이브러리가 그 불편하고 시간이 오래걸리던일을 해결한건지에 대한 약간의 공부와 시간투자는 필요하겠지만 말이죠 ㅎㅎ

그럼 한번 오늘도 힘차게 시작해보겠습니다

TableView & CollectionView in Application

혹시 앱을 만들때 가장 자주쓰는 기능이 뭐라고 생각하시나요?
아마도 tableview나 collectionview일겁니다
그렇다는 말은 tableview나 collectionview를 잘쓰는게 UI를 그릴때 꽤나 중요하다는 이야기또한 될수있습니다

제가 지금까지 만들었던 많은 뷰들이 세세하게는 조금씩 다를수있어도 큰 그림은 테이블뷰 혹은 컬렉션뷰였습니다

위 사진을 보시면 제가 최근에 진행했던 프로젝트에도 거의 대부분의 뷰에 테이블뷰 혹은 컬렉션뷰가 있었습니다
아마 iOS개발을 하시는 분들이라면 테이블뷰 혹은 컬렉션뷰의 사용법은 알고계실정도로 앱개발에서는 중요한 요소입니다

그런데 혹시 swiftUI에서 테이블뷰나 컬렉션뷰를 사용해보신적이 있으신가요?

만약에 아래와같은 data가 주어진다고 했을때 이걸가지고 위의 사진처럼 컬렉션뷰를 만들려면 UIkit에서는 고려해야할게 결코 적지 않습니다

let data = ["one","two","three","four","five","six"]

우선 cell만들어줘야하고 cell의 identifier도 정해줘야합니다
cell만들때는 어떤가요 codebaseUI라면 label만들고 addsubview해주고 autolayout으로 constraints를 일일히 잡아줘야합니다
cell만들고나면 collectionview만들어서 UICollectionViewFlowLayout객체도 만들어서 넣어줘야합니다
이러면또 끝나는게 아니죠 datasource혹은 delegate까지 채택해주고 datasource의 필수 메서드를 구현해줘야합니다
cell의 갯수, dequeue를 위한 cell정의 등등등

그렇게하다보면 이렇게 간단해보이는 뷰하나를 만드는데에도 코드가 여럿필요하게됩니다

근데 스유로 만들면 어떻게될까요

다른 파일 필요없이 20줄도 안되는 코드로 컬렉션뷰모양의 UI를 완성할 수 있게됩니다

어떠신가요... 기존에 우리가 만들었던 컬렉션뷰나 테이블뷰가 익숙함이라는걸 빼버리면 꽤나 복잡하게 만들고있었다는 생각이 들지 않나요??


@ViewBulider? @resultBuilder?

그렇다면 swiftUI에서는 어떻게 이렇게 간단하게 list(앞으로는 tableview혹은 collectionview를 list라고 하겠습니다)를 구현할 수 있었을까요?
swiftUI에서는 tableview나 collectionview가 아닌 Grid로 list형태의 UI를 그릴수있습니다

그러면 이 Grid라는 녀석이 어떻게 구현되어있는지(추상화되어있는지) 보겠습니다

보니까 content라는 녀석이 주석으로 되어있는데 grid의 content라고 설명이되어있네요 그러면 저게 아마 UIkit의 cell들쯤 되는 녀석일것같습니다

content가 어떤 타입인지를 보는데 클로저타입이네요? void를 받아서 content를 반환해주는 클로저타입입니다. 근데 그 앞에 @가 붙은 ViewBuilder라는 녀석이 붙어있네요

모를때는 공식문서를 한번 보죠
우선 ViewBuilder는 클로저로부터 뷰를 구성하는 사용자정의매개변수attribute(속성)라고합니다
네... 무슨말인지 잘 모르겠습니다
결국 클로저에 붙는 attribute인거같구요 클로저는 하위뷰들을 생성해주는 클로저인것같습니다

즉 우리가 하위뷰들을 정의할수있는 클로저앞에 @ViewBuilder를 명시해주면 내부적인 로직으로 클로저내부에있는 뷰들을 하위뷰로 그려주고 정의해주는 역할을 한다고 볼수있을것같습니다

근데 두번째 박스를 보면 ViewBuilder는 struct인데 위에 @resultBuilder라는 attribute가 하나 더 붙어있습니다

그럼 결국 viewbuilder를 이해하기위해서는 @resultbuilder를 이해해야하고 그렇다면 우리가 uikit에서도 swiftUI처럼 list를 구현하는 방법을 이해할수있게 될겁니다


@resultBuilder

서론이 길었는데 오늘의 주제는 @resultBuilder를 이해하는것입니다

애플 공식문서에서는 Result Builder를 위와같이 정의합니다
한번 어렵지만 읽어보죠

result builder는 natural하고 선언적인 방식으로 목록이나 트리같은 중첩데이터를 추가하기위해 syntax(구문)을 추가하는 사용자정의타입입니다
result builder를 사용한 코드는 if문이나 for문같은 데이터들의 반복이나 조건을 처리할수있는 swift구문을 사용할 수 있습니다

요약을 해보면 resultBuilder는 무작정 나열된 데이터들에 문맥과 그 의미를 더하고 해석해주는 규칙을 만들어주는 타입이라고 할 수 있습니다

이게 무슨 의미냐면 resultBuilder를 통해서 예를들어서 string값들을 전부 이어붙여줘라는 규칙을 적용시킬수있는 타입이라는겁니다

그렇다면 viewbuilder의경우엔 내부적으로는 어떻게 구현되어있는지는 모르지만 grid에서의 viewbuilder는 view들을 여러개받으면 그걸 list형태로 그려줘라는 문맥과 규칙을 적용해주는 resultbuilder라고 할 수 있습니다

그렇다면 우리도 uikit에서 uiview같은걸 여러개 받았을때 그걸 uitableviewCell로 만들어서 uitableview에 넣어줘라는 문맥과 규칙을 잘정의해줄 수 있다면 uikit에서도 swiftUI처럼 선언형으로 list형태의 UI를 간편하게 구현할 수있게될겁니다

resultBuilder라는 녀석은 잘쓰면 엄청 편리해지겠는걸?

이라는 생각이 드셨다면 한번 resultBuilder에 대한 애플공식예제를 보면서 실제로 어떻게 쓰는지를 공부해보겠습니다

@resultBuilder예제

protocol Drawable {
    func draw() -> String
}

draw라는 메서드를 호출하면 string값이나오는 메서드를 추상화한 Drawable프로토콜이 있다고 해보겠습니다

그리고 Drawable을 통해 할수있는 action이 아래와 같다고 해볼게요

struct Line: Drawable {
    var elements: [Drawable]
    func draw() -> String {
        return elements.map { $0.draw() }.joined(separator: "")
    }
}

struct Text: Drawable {
    var content: String
    init(_ content: String) { self.content = content }
    func draw() -> String { return content }
}

struct Space: Drawable {
    func draw() -> String { return " " }
}

struct Stars: Drawable {
    var length: Int
    func draw() -> String { return String(repeating: "*", count: length) }
}

struct AllCaps: Drawable {
    var content: Drawable
    func draw() -> String { return content.draw().uppercased() }
}

Line은 init에서 Drawable타입의 객체를 list형태로받아서 모든 객체의 draw메서드를 호출한 결과를 이어붙어서 최종적으로 하나의 string이 반환되게됩니다

text는 string을 받아서 그냥 그걸 그대로 반환해주고
space는 띄어쓰기용 한칸짜리 빈 string을 반환해주고
stars는 갯수를 받아서 그갯수만큼 *을 찍어서 string으로 반환해줍니다
AllCaps는 drawable객체를 받아서 draw해준결과를 모두 대문자로바꿔서 반환해주네요

let name: String? = "Ravi Patel"

let manualDrawing = Line(elements: [
     Stars(length: 3),
     Text("Hello"),
     Space(),
     AllCaps(content: Text((name ?? "World") + "!")),
     Stars(length: 2),
])

print(manualDrawing.draw())

위코드의 결과는 어떻게될까요? 아마도 "***Hello RAVI PATEL!**"라는 문자열이 출력될겁니다

이렇게쓴다면 아마도 그렇게 불편하지 않아보이는데요?라고 하실수도있습니다
하지만 text내부의 name에 삼항연산자를 쓴것 자체가 우선 가독성을 떨어뜨리는 문제가있습니다

그정도는 넘어갈수있다고 하더라도 약간의 복잡한 분기처리가 필요하다면 if문을 써야하는데 if문을 내부에서 사용할 수 없습니다 혹은 for문을 사용했을때 편한경우에 위와같은 방식으로는 for문을 처리할수가 없습니다

처음에 resultBuilder에 대한 애플 공식문서에서 resultBuilder를 사용하면 if문 for문을 처리할수가있고 이는 코드를 유연하게 작성할수있게 해줍니다

if문을 제대로 사용해보기 위해서 @resultBuilder를 사용해보겠습니다

@resultBuilder
struct DrawingBuilder {
    static func buildBlock(_ components: Drawable...) -> Drawable {
        return Line(elements: components)
    }
    static func buildEither(first: Drawable) -> Drawable {
        return first
    }
    static func buildEither(second: Drawable) -> Drawable {
        return second
    }
}

@resultBuilder를 사용하기위해선 buildBlock이라는 타입메서드를 필수적으로 구현해줘야합니다

그리고 buildBlock에서 여러개의 요소들을 어떤 규칙을 통해 반환해줄지에대한 정의를 해줍니다

여기서는 Drawable들을 Line이라는 객체의 elements에 넣는다는 규칙을 정의해줍니다

그리고 규칙을 세분화해서 정의해줄수있는 여러가지 메서드들이있습니다

1. buildEither

  • parameter가 first와 second로 나눠지고, if에 있는게 first, else에 있는게 second라고 합니다

2. buildArray

  • Array와 for 절을 처리하기 위한 메서드다. for로 생성되는 component들도 Array로 인식되어 이 메서드가 처리한다고 합니다

즉 위의 메서드들을 정의해줘야 if문과 for문을 사용할수있게됩니다

여기서는 if문이 참이라면 if문의 결과를 반환해주고 else문이 참이라면 else문의 결과를 반환해줄겁니다(당연한거겠죠)

func draw(@DrawingBuilder content: () -> Drawable) -> Drawable {
    return content()
}
func caps(@DrawingBuilder content: () -> Drawable) -> Drawable {
    return AllCaps(content: content())
}


func makeGreeting(for name: String? = nil) -> Drawable {
    let greeting = draw {
        Stars(length: 3)
        Text("Hello")
        Space()
        caps {
            if let name = name {
                Text(name + "!")
            } else {
                Text("World!")
            }
        }
        Stars(length: 2)
    }
    return greeting
}
let genericGreeting = makeGreeting()
print(genericGreeting.draw())

그렇다면 결과는 "***Hello WORLD!**"가 될겁니다 왜냐면 draw라는 메서드를 호출했을때 content라는 클로저를 받아서 실행시키는데 결과적으로 Line이라는 객체에 클로저내부의 요소들을 넣어 반환해준다는 규칙을 실행해주게됩니다

그럼결국 Drawable의 Line이 반환되고 그 객체의 draw를 실행하게됩니다

실제로 if문을 호출하기 위해서는 buildEither메서드를 구체화해줘야하는데 실행하게되는 context를 공식문서에서는 아래와같이 설명하고있습니다

let capsDrawing = caps {
    let partialDrawing: Drawable
    if let name = name {
        let text = Text(name + "!")
        partialDrawing = DrawingBuilder.buildEither(first: text)
    } else {
        let text = Text("World!")
        partialDrawing = DrawingBuilder.buildEither(second: text)
    }
    return partialDrawing
}

if문이 참이라면 그 결과를 buildEither(first:_)에 넣어서 그 값을 반환해주고 else가 참이라면 그 결과를 buildEither(second:_)에 넣어서 그 값을 반환해줍니다 결국 반환되는 값이 Drawable타입이기때문에 동일타입임이 보장되게됩니다

for문을 처리하기위한 buildArray도 있었는데요

extension DrawingBuilder {
    static func buildArray(_ components: [Drawable]) -> Drawable {
        return Line(elements: components)
    }
}
let manyStars = draw {
    Text("Stars:")
    for length in 1...3 {
        Space()
        Stars(length: length)
    }
}

이렇게 buildArray를 구현해서 for문을 통해 반복적으로 실행된 요소들을 어떻게처리할지에대한 규칙을 정의해주면(여기서는 모든 요소들을 line의 생성자에넣어준다) 그 규칙에따라 결과를 반환해줍니다. 이러면 "Stars: * * *"가 반환되게됩니다

즉 @resultBuilder따르는 어노테이션을 클로저앞에 붙이면 그 클로저내부에 있는 여러요소들을 @resultBuilder의 static메서드인 buildBlock의 규칙대로 재정의를 해주게됩니다

그래서 이걸 어떻게 써야하나요?

UIkit의 tableview를 생각해보면 우리가 어떤 요소를 cell내부에 넣어주고 그 cell을 tableview에 register해주고 그리고 cell을 datasource에서 재정의를 해준다면 우리가 @resultBuilder를 따르는 특정 builder에 cell내부의 view만 넣어줘도 swiftUI에서처럼 선언형으로 listUI를 구현할 수있게됩니다

만약에 그렇다고하면 복잡한 list를 그리면 그릴수록 빠르고 쉽게 UI를 그리고 원하는 action을 동작시킬수있게될겁니다

그리고 그런방식으로 UIkit에서 선언형으로 listUI를 구현하게해주는 라이브러리가 있습니다

바로 carbon이라는 라이브러리입니다
다음 포스팅부터는 carbon을 이용해서 쉽게 listUI를 그리는 방법에대해서 알아보겠습니다:)

https://github.com/ra1028/Carbon

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글