본문 바로가기
Swift

[Swift] 메서드 체이닝을 활용하여 UIKit을 SwiftUI처럼 사용하기

by Jimmy_iOS 2023. 11. 13.

View 객체를 생성할 때 어떻게든 코드를 편하게 작성해보려고 노력해봤지만

기본 UIKit으로는 다음과 같이 가독성을 높이는데 한계점이 있었습니다.

lazy var button1: UIButton = {
    let button = UIButton()
    var config = UIButton.Configuration.plain()
    config.title = "Plain Button"
    config.subtitle = "SubTitle"
    config.buttonSize = .medium
    config.image = UIImage(systemName: "swift")
    button.configuration = config
    button.addTarget(self, action: #selector(button1Tapped), for: .touchUpInside)
    return button
}()

@objc func button1Tapped() {
    print("Button Tapped")
}

위 예시에서는 SwiftUI의 Button을 사용하여 버튼을 생성하고, 버튼을 눌렀을 때 호출되는 함수를 정의합니다. 코드가 간결하고 직관적이며, 별도의 클로저나 추가 작업이 필요하지 않습니다.

클로저를 사용하여 들여쓰기를 하고,

클로저 끝에 return을 작성하며,

()를 추가하여 클로저를 실행하는 것과 같은 번거로운 작업이 계속되었습니다.

또한, 프로퍼티 앞에 lazy var를 붙여 클로저를 실행하기 위한 추가 작업도 수행해야 했습니다.

SwiftUI의 메서드 체이닝

UIKit 대신 SwiftUI를 사용하여 버튼을 만드는 예시입니다.

Button(action: {
    button1Tapped()
}, label: {
    Text("Plain Button")
        .font(.title)
        .foregroundColor(.white)
        .padding()
        .background(Color.blue)
        .cornerRadius(10)
})
}

func button1Tapped() {
    print("Button Tapped")
}

저는 SwiftUI와 같이 메서드 체이닝을 활용하여 깔끔하게 UI 객체를 만드는 코드를 작성하고 싶었습니다.

그러다가 "빌더 패턴"이라는 것을 알게 되었는데요.

[Swift 디자인 패턴] Builder Pattern (빌더) - 디자인 패턴 공부 3
안녕하세요 Pingu입니다.🐧 지난 글에서는 Abstract Factory 패턴에 대해 알아봤었는데요, 이번 글에서는 또 다른 Creational Pattern인 Builder Pattern에 대해 알아보려고 합니다. 빌더 패턴이란? 복잡하게 생성되어야 할 객체를 구현할 때 구성을 분리하여 다른 표현으로 만들 수 있게 하는 패턴입니다. 여기서 표현이라고 하니 좀 와 닿지 않는데요, 간단하게 동일한 역할을 하는 다른 코드로 만들 수 있다고 볼 수 있습니다. 즉 어떤 객체를 생성자로 만들 때 한 번에 모두 만들 수도 있지만 객체가 가지는 요소가 많다면 여러 단계로 나누어 객체를 만들 수 있게 하는 패턴입니다. 빌더 패턴은 위의 그림과 같이 3가지 요소로 나눌 수 있어요. Director input을 받고 b..
https://icksw.tistory.com/236

빌더 패턴을 잘 활용해 UIButton의 프로퍼티를 메서드화 하면

제가 원하는 방식을 구현할 수 있을 것 같았습니다.

ButtonBuilder

@available(iOS 15.0, *)
public struct ButtonBuilder {
    private var button = UIButton()
    
    public enum ButtonStyle {
        case plain
        case tinted
        case gray
        case filled
        case borderless
        case bordered
        case borderedTinted
        case borderedProminent
    }
    
    
    public init(_ style: ButtonStyle) {
        switch style {
        case .plain:
            button.configuration = UIButton.Configuration.plain()
        case .tinted:
            button.configuration = UIButton.Configuration.tinted()
        case .gray:
            button.configuration = UIButton.Configuration.gray()
        case .filled:
            button.configuration = UIButton.Configuration.filled()
        case .borderless:
            button.configuration = UIButton.Configuration.borderless()
        case .bordered:
            button.configuration = UIButton.Configuration.bordered()
        case .borderedTinted:
            button.configuration = UIButton.Configuration.borderedTinted()
        case .borderedProminent:
            button.configuration = UIButton.Configuration.borderedProminent()
        }
    }
    
    public func makeButton() -> UIButton {
        return button
    }

    public func baseBackgroundColor(_ color: UIColor) -> Self {
        button.configuration?.baseBackgroundColor = color
        return self
    }
    
    public func baseForegroundColor(_ color: UIColor) -> Self {
        button.configuration?.baseForegroundColor = color
        return self
    }
//...

의존성 관리 도구인 SPM을 통해 오픈소스 라이브러리를 만들고

접근 제어자를 통해 외부 모듈에서도 동작 할 수 있게 개발을 했습니다.

이 라이브러리를 사용해면 다음과 같이 버튼을 만들 수 있습니다.

사용 예시 코드

let button1: UIButton = ButtonBuilder(.plain)
    .title("Plain Button")
    .subtitle("SubTitle")
    .buttonSize(.medium)
    .image(UIImage(systemName: "swift"))
    .addAction { [unowned self] in
        print("Button Tapped")
    }
    .makeButton()

기존 UIButton에 있는 프로퍼티명과 동일한 메서드를 구현하고

ButtonBuilder의 프로퍼티인 button의 속성을 변경해주며,

메서드 체이닝을 위해 반환 타입으로 Self를 사용했습니다.

하. 지. 만

이렇게 사용하다 보니 버튼뿐만 아니라 다른 UI 객체에도 Extension으로 구현할 수 있을 것 같습니다.

ButtonBuilder와 같은 빌더 패턴 없이도 메서드 체이닝을 구현할 수 있을 것 같은데요.

다음과 같이 Stylable 프로토콜을 생성해주고 UIView에 extension으로 프로토콜을 채택해줍니다.

public protocol Stylable {}

extension UIView: Stylable {}

그런 다음, extension을 사용하여 Stylable 중 UIView를 확장해줍니다.

extension Stylable where Self: UIView {
    
    @discardableResult
    public func cornerRadius(_ radius: CGFloat) -> Self {
        self.layer.cornerRadius = radius
        return self
    }
    
    @discardableResult
    public func backgroundColor(_ color: UIColor) -> Self {
        self.backgroundColor = color
        return self
    }
// ...

여기서 발생하는 궁금증

왜 UIView를 직접 확장하는 대신 Stylable 프로토콜을 사용하여 확장할까요?

UIView를 직접 확장하면 다른 모듈에서도 해당 확장을 사용할 수 있기 때문인데요.

Stylable 프로토콜을 사용하여 확장하면 해당 모듈 내에서만 사용할 수 있습니다.

이렇게 하면 의존성을 관리하고 모듈 간의 결합도를 낮출 수 있습니다.

따라서 Stylable 프로토콜을 활용하여 UIView를 확장함으로써,

다른 모듈의 UIView 확장과 구별하여 사용할 수 있습니다.

확장해준 메서드에는 Self타입을 반환하여 Builder 필요 없이 바로 UIView객체에 메서드체이닝을 적용할 수 있습니다.

다른 UI객체 확장

이와 같은 방식으로 다른 UI 객체들에도 extension을 사용하여 메서드를 구현할 수 있습니다.

extension Stylable where Self: UITextField { ... }
extension Stylable where Self: UIStackView { ... }
extension Stylable where Self: UIButton { ... }

그러면 다음과 같은 방식으로 메서드 체이닝이 가능합니다.

예시 코드

let view = UIView()
    .backgroundColor(.red)
    .cornerRadius(8)
    .clipsToBounds(true)
    .addSubView(textField)

let button = UIButton()
    .title("Plain Button")
    .subtitle("SubTitle")
    .buttonSize(.medium)
    .image(UIImage(systemName: "swift"))
    .addAction { [unowned self] in
        print("Button Tapped")
    }

let textField = UITextField()
    .text("Enter your name")
    .textColor(.black)
    .font(UIFont.systemFont(ofSize: 16))
    .placeholder("Name")

그러면 확장한 객체의 메서드에 @discardableResult은 왜 사용할까요?

@discardableResult을 사용한 이유

@discardableResult
public func cornerRadius(_ radius: CGFloat) -> Self {
    self.layer.cornerRadius = radius
    return self
}

corerRadius(_) 메서드를 사용하면 UIView 객체가 반환됩니다.

컴파일러는 corerRadius에서 반환된 객체가 사용되지 않았다는 경고를 표시합니다.

이 경고를 제거하기 위해 @discardableResult를 사용하면 컴파일러의 경고를 무시할 수 있습니다.

하.지.만

이렇게 구현하면 모든게 완벽할 것 같지만

치명적인 단점이 존재합니다.

바로 제가 UI객체의 모든 프로퍼티를 메서드로 구현해주지 않으면

UI객체의 모든 기능을 사용할 수 없다는 것인데요…

UIView의 하위 객체는 너무나도 많기 때문에 모든 기능을 이해하고 구현하기에는

자신이 없었습니다…

그래서 다른 해결방법이 필요했는데 마침 Then 라이브러리의 구현방법을 찾아보게 되었습니다.

Then

then은 UI 객체뿐만 아니라 NSObject,

즉 모든 클래스 타입에 적용하여 코드를 더 간편하게 작성할 수 있게 되었습니다.

let label = UILabel().then {
  $0.textAlignment = .center
  $0.textColor = .black
  $0.text = "Hello, World!"
}

내구 구현 코드를 보니 정말 간단했는데요

extension Then where Self: AnyObject {

  /// Makes it available to set properties with closures just after initializing.
  ///
  ///     let label = UILabel().then {
  ///       $0.textAlignment = .center
  ///       $0.textColor = UIColor.black
  ///       $0.text = "Hello, World!"
  ///     }
  @inlinable
  public func then(_ block: (Self) throws -> Void) rethrows -> Self {
    try block(self)
    return self
  }

}

extension NSObject: Then {}

then 메서드는 클로저를 매개변수로 받으며,

Self를 인자로 받습니다.

클로저 내에서 객체의 속성을 설정하거나 필요한 구성을 수행할 수 있습니다.

마지막으로, then 메서드는 Self 타입을 반환하여 여러 개의 구성 호출을 연결할 수 있습니다.

이 방법을 잘 활용해 빌더패턴과 결합하면 모든 class 객체에서

메서드 체이닝을 구현할 수 있을것 같습니다.

@dynamicMemberLookup + KeyPath

다음과 같은 방법으로 메서드 체이닝을 구현할 수 있었습니다.

우선 코드부터 보고 설명을 아래에서 진행하도록 하겠습니다.

@dynamicMemberLookup
public struct Builder<BaseObject: AnyObject> {
    private let _build: () -> BaseObject
    public init(_ build: @escaping () -> BaseObject) {
        self._build = build
    }
    public init(_ base: BaseObject) {
        self._build = { base }
    }
    public subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<BaseObject, Value>) -> (Value) -> Builder<BaseObject> {
        { [build = _build] value in
            Builder {
                let object = build()
                object[keyPath: keyPath] = value
                return object
            }
        }
    }
    public func build() -> BaseObject {
        return _build()
    }
}
public protocol BuilderProtocol {
    associatedtype BuilderObject: AnyObject
    var builder: Builder<BuilderObject> { get set }
}
extension BuilderProtocol where Self: AnyObject {
    @inlinable
    public var builder: Builder<Self> {
        get { Builder(self) }
        set {}
    }
}
extension NSObject: BuilderProtocol {}

사용 예시

let button = UIButton().builder
    .title("Plain Button")
    .subtitle("SubTitle")
    .buttonSize(.medium)
    .image(UIImage(systemName: "swift"))
		.addAction({
        print("Button Tapped")
    }, .touchUpInside)
    .build()

let view = UIView().builder
    .backgroundColor(.red)
    .cornerRadius(8)
    .clipsToBounds(true)
    .addSubView(textField)
    .build()

BuilderProtocol

public protocol BuilderProtocol {
    associatedtype BuilderObject: AnyObject
    var builder: Builder<BuilderObject> { get set }
}
extension BuilderProtocol where Self: AnyObject {
    @inlinable
    public var builder: Builder<Self> {
        get { Builder(self) }
        set {}
    }
}
extension NSObject: BuilderProtocol {}

BuilderProtocol은 builder 프로퍼티를 가지고 있는데요

builder 프로퍼티는 Builder 타입을 반환합니다.

public struct Builder<BaseObject: AnyObject> { ... }

Builder 객체는 제네릭 타입으로 모든 클래스를 받아들일 수 있습니다.

let view = UIView().builder //builder는 Builder(UIView)를 반환합니다

Builder의 init은 () -> BaseObject 클로저를 _build 객체에 전달합니다.

private let _build: () -> BaseObject
public init(_ base: BaseObject) {
        self._build = { base }
    }

아래와 같이 타입을 직접 소유하지 않는 이유는

_build 객체는 BaseObject를 강하게 참고하고 있기 때문에

강한 순환 참조가 발생할 수 있기 때문입니다.

private var _build: BaseObject
public init(_ build: @escaping () -> BaseObject) {
    self._build = build()
}
public init(_ base: BaseObject) {
    self._build = base
}

따라서 private let _build: () -> BaseObject와 같이 클로저를 사용하면

클로저의 캡처 현상으로 인해 강한 순환 참조로부터 안전해집니다.

다음은 가장 이해하기 어려운 코드인 @dynamicMemberLookup 와 ReferenceWritableKeyPath 입니다.

@dynamicMemberLookupKeyPath에 대해 더 알고 싶으시면 아래 포스팅을 참고해 주세요.

[Swift] `@dynamicMemberLookup과 KeyPath 그리고 WritableKeyPath<Root, Value>`
KeyPath는 Swift의 기능 중 하나로, 타입 안전한 속성 또는 서브스크립트에 대한 참조를 나타냅니다. 이를 사용하면 속성이나 서브스크립트의 값을 타입 안전하게 저장하고 조작할 수 있습니다. 예를 들어, Person이라는 구조체를 정의하고 name이라는 속성을 가지고 있다고 가정해봅시다. 이때 Person 구조체의 인스턴스에서 name 속성에 접근하고자 한다면, KeyPath를 사용하여 타입 안전하게 이러한 속성에 접근할 수 있습니다.struct Person { let name: String } let person = Person(name: "jimmy") let nameKeyPath = \Person.name person[keyPath: nameKeyPath] // "jimmy" person[ke..
https://jimmy-ios.tistory.com/41

@dynamicMemberLookup + ReferenceWritableKeyPath

@dynamicMemberLookup
public struct Builder<BaseObject: AnyObject> {
    private let _build: () -> BaseObject
    public init(_ build: @escaping () -> BaseObject) {
        self._build = build
    }
    public init(_ base: BaseObject) {
        self._build = { base }
    }
    public subscript<Value>(
        dynamicMember keyPath: ReferenceWritableKeyPath<BaseObject, Value>
    ) -> (Value) -> Builder<BaseObject> {
        { [build = _build] value in
            Builder {
                let object = build()
                object[keyPath: keyPath] = value
                return object
            }
        }
    }
    public func build() -> BaseObject {
        return _build()
    }
}

@dynamicMemberLookup와 KeyPath를 함께 사용하여 메서드 체이닝을 구현할 수 있습니다.

이를 통해 객체의 속성을 간편하고 유연하게 설정할 수 있습니다. 아래는 예시 코드입니다:

@dynamicMemberLookup
public struct Builder<BaseObject: AnyObject> {
    private let _build: () -> BaseObject
    public init(_ build: @escaping () -> BaseObject) {
        self._build = build
    }
    public init(_ base: BaseObject) {
        self._build = { base }
    }
    public subscript<Value>(
        dynamicMember keyPath: ReferenceWritableKeyPath<BaseObject, Value>
    ) -> (Value) -> Builder<BaseObject> {
        { [build = _build] value in
            Builder {
                let object = build()
                object[keyPath: keyPath] = value
                return object
            }
        }
    }
    public func build() -> BaseObject {
        return _build()
    }
}

위의 코드에서 Builder 구조체는 @dynamicMemberLookup 속성을 사용하여 ReferenceWritableKeyPath를 동적 멤버로 받아들입니다.

subscript 메서드를 사용하여 KeyPath를 받아들이고, 클로저를 반환합니다.

이 클로저는 값을 받아들여 해당 속성을 설정하고, 다시 Builder 객체를 반환합니다.

이를 반복하여 여러 속성을 설정한 뒤, build() 메서드를 호출하여 최종 객체를 반환합니다.

예를 들어, UIButton을 생성하고 여러 속성을 설정하는 예시를 살펴보겠습니다:

let button = UIButton().builder
    .title("Plain Button")
    .subtitle("SubTitle")
    .buttonSize(.medium)
    .image(UIImage(systemName: "swift"))
		.addAction({
        print("Button Tapped")
    }, .touchUpInside)
    .build()

위의 코드에서 button은 UIButton 객체를 생성하고 builder를 사용하여 여러 메서드를 체이닝하여 속성을 설정하고 있습니다.

UIButton().builder.title("Plain Button")의 title은 UIButton의 프로퍼티입니다.

ReferenceWritableKeyPath<BaseObject, Value>에서 Value는 String으로 추론됩니다.

(Value) -> Builder<BaseObject>에서 (Value)로 String 값을 파라미터로 받아 메서드 체이닝이 가능하게 됩니다.


Uploaded by N2T