How to Create Extensions for Arbitrary Types in Swift?

Hi! I am writing a UIKit dsl library. My goal is to provide fluent api for UIView types. Like

UILabel()
    .set(\.text, "Hello World")
    .set(\.textColor, .label)

To create a generic set function I tried this:

extension Self where Self: UIView {
    @discardableResult
    mutating func set<Property>(
        _ keyPath: WritableKeyPath<Self, Property>,
        _ value: Property
    ) -> Self {
        self[keyPath: keyPath] = value
        return self
    }
}

It seems this is not supported in current swift. As a workaround I created wrapper type like this:

final class Create<View> where View: UIView {
    public var view: View

    public init(_ view: View) {
        self.view = view
    }

    @discardableResult
    public func set<Property>(
        _ keyPath: WritableKeyPath<View, Property>,
        _ value: Property
    ) -> Self {
        view[keyPath: keyPath] = value
        return self
    }
}

With this I can write the same code above as this:

Create(UILabel())
    .set(\.text, "Hello World")
    .set(\.textColor, .label)

This seems working but it creates syntax noise. So my question is how can I make this more clear?

One common solution is generic configuration function which I call with:

public func with<Value>(
    _ value: Value,
    do body: (_ value: inout Value) throws -> ()
) rethrows -> Value {
    var value = value
    try body(&value)
    return value
}

We'd apply with to your example as follows:

let label = with (UILabel()) {
    $0.text = "Hello World"
    $0.textColor = .label
}

And here's an example that uses with in-line, copied from one of my projects:

let request = UNNotificationRequest(
    identifier: posting.id,
    content: with (UNMutableNotificationContent()) {
        $0.title = note.title
        $0.subtitle = note.subtitle
        $0.body = note.body
        $0.sound = note.soundName.map { UNNotificationSound(named: .init($0)) } ?? .default
    },
    trigger: nil
)

Sometimes you will see a function like this defined as a member of a type instead of as a top-level function. For example, swift-protobuf defines it as a static member of the Message protocol, like this:

extension Message {
  public static func with(
    _ populator: (inout Self) throws -> ()
  ) rethrows -> Self {
    var message = Self()
    try populator(&message)
    return message
  }
}

It lets you create and populate an instance of any protobuf message. Here's an example from one of my projects:

let message = AppMessage.with {
    $0.type = .loginStatus
    $0.loginStatus = .with {
        $0.user = "test"
        $0.state = .loginSuccess
        $0.system = .demo
        $0.region = .us
    }
}

On the other hand, if you don't like the with function idea, you can make your keypath function work using a protocol. You can find the trick used in (for example) Publishers+KeyValueObserving.swift, which implements the Combine publisher(for:options:) method on NSObject.

The trick is to define a protocol, conform UIView (or even NSObject) to the protocol, and then extend the protocol where Self: UIView (or Self: NSObject). Thus:

protocol _KeyPathSetting { }

extension UIView: _KeyPathSetting { }

extension _KeyPathSetting where Self: UIView {
    @discardableResult
   func set<Property>(
        _ keyPath: ReferenceWritableKeyPath<Self, Property>,
        _ value: Property
    ) -> Self {
        self[keyPath: keyPath] = value
        return self
    }
}
2 Likes

I go with protocol extension option. Now the code is looking much cleaner. Here is an example:

private func PizzaCell() -> UIStackView {
    HorizontalStackView(alignment: .center, spacing: 10) {
        UIImageView()
            .set(\.image, UIImage(named: "pizzaHutLogo"))

        VerticalStackView(alignment: .leading, spacing: 5) {
            UILabel()
                .set(\.text, "Pizza Hut Pizza Hut Pizza Hut Pizza Hut")
                .set(\.font, .boldSystemFont(ofSize: 14))
                .set(\.numberOfLines, 2)
                .set(\.textColor, UIColor(white: 51/255, alpha: 1))

            UILabel()
                .set(\.text, "İtalyan Mutfağı")
                .set(\.font, .systemFont(ofSize: 10))
                .set(\.textColor, UIColor(white: 102/255, alpha: 1))

            RatingLabel(rating: "4.8")
        }
    }
}

Thank you very much @mayoff

1 Like