Skip to content

Instantly share code, notes, and snippets.

@LuizZak
Last active February 22, 2019 00:50
Show Gist options
  • Save LuizZak/a5266b78cd3664baf1946354ccd367af to your computer and use it in GitHub Desktop.
Save LuizZak/a5266b78cd3664baf1946354ccd367af to your computer and use it in GitHub Desktop.
A chainable, storable, composable configuration object featuring Swift keypaths

With.swift - A chainable, storable, composable configuration object featuring Swift keypaths

Based on some source I don't exactly remember right now but will add a link to later.

// When used on instances, allows changing fields directly using keypaths (handy way to avoid 'lazy var' property closures!)
let label
    = UILabel()
        .with(\.text, setTo: "Hello!")
        .with(\.textColor, setTo: UIColor.darkGray)

// When used on types, creates a configuration object that can configure multiple instances the same way
let config
    = UILabel
        .with(\.text, setTo: "Hello!")
        .with(\.textColor, setTo: UIColor.darkGray)

label1.with(config: config)
label2.with(config: config)

Sample usage:

enum Styling {
    enum Label {
        static let `default`
            = UILabel
                .with(\.numberOfLines, setTo: 2)
                .with(\.font, setTo: UIFont.systemFont(ofSize: 13))
    }
}

extension MyView {
  func configStyle() {
    self.myLabel.with(config: Styling.Label.default)
    
    self.myLabel2
      .with(config: Styling.Label.default)
      .with(\.numberOfLines, setTo: 0) // Overwrite value from config
  }
}

To make your own types that don't inherit from NSObject 'withable', just extend them implementing 'With':

extension MyClass: With { }
// Structs are supported too!:
extension MyStruct: With { }

If you'd like to keep your autocomplete clean of a million withs, you can also use configuration objects directly instead:

class MyClass {
    var field: String = "abc"
}

Configuration<MyClass>().with(\.field, setTo: "def").apply(to: myClassInstance)

Fun stuff

  • You can create configuration objects and apply tests later on them to verify their contents:
// Styling.swift
enum Styling {
    enum Label {
        static let `default`
            = UILabel
                .with(\.numberOfLines, setTo: 2)
                .with(\.font, setTo: UIFont.systemFont(ofSize: 13))
    }
}
// StylingTests.swift
func testLabelDefault() {
    XCTAssertEqual(Styling.Label.default.value(forKeypath: \.numberOfLines), 2)            
    XCTAssert(Styling.Label.default.value(forKeypath: \.font) == UIFont.systemFont(ofSize: 13))
}
  • You can apply configs on nested objects, as well:
class UserCellView: UITableViewCell {
    var userNameLabel = UILabel()
    var avatarImageView = UIImageView()
}

extension Styling {
    enum ImageView {
        static let `default`
            = UIImageView.with(\.contentMode, setTo: .scaleAspectFit)
    }
    enum UserCell {
        public static let `default`
            = UserCellView
                .with(\.userNameLabel, configuredWith: Styling.Label.default)
                .with(\.avatarImageView, configuredWith: Styling.ImageView.default)
    }
}
  • If you composed a monstrous multi-filed Configuration<T> object and want to inspect where all its configurations came from, you can inspect its .debugDescription for a list of each keypath config, and the file/line they came from:
print(myCellDefaultConfig.debugDescription)
Configuration<MyCell> - keypath count: 3
----
Stylings.swift line #3: Optional<String> = Optional("Hello, User!")
Stylings.swift line #4: UILabel =
  Configuration<UILabel> - keypath count: 1
  ----
  Stylings.swift line #12: ImplicitlyUnwrappedOptional<UIFont> = <UICTFont: 0x7f898cc17a30> font-family: ".SFUIText"; font-weight: normal; font-style: normal; font-size: 13.00pt
Note: Due to limitations on printing KeyPath descriptions, you cannot actually inspect the full keypath with all dots and stuff in-between, i.e. no nice MyCell.lblAnnounce.text keypath strings.

Neat-o, right?

import Foundation
// MARK: - Type Definitions
/// Represents an object able to be configured with a `Configuration<T>`
public protocol Configurable { }
/// Shortcut protocol to enable applying and creating configurations using `.with`
/// methods on instance and class types of implementers.
public protocol With: Configurable { }
public struct Configuration<T>: CustomDebugStringConvertible {
private var configurations: [AnyEntry] = []
public var debugDescription: String {
var descriptions: [String] = [
"\(type(of: self)) - keypath count: \(configurations.count)",
"----"
]
for config in configurations {
descriptions.append(config.debugDescription)
}
return descriptions.joined(separator: "\n")
}
public init() {
}
public func applied(on target: T) -> T {
var target = target
for config in configurations {
target = config.closure(target)
}
return target
}
public func apply(on target: inout T) {
target = applied(on: target)
}
/// Fetches a nested Configuration for a given keypath
public func configuration<U>(forKeypath keyPath: KeyPath<T, U>) -> Configuration<U>? {
return
configurations.lazy.compactMap { entry in
entry.typedEntry as? TypedEntry<U>
}.compactMap { config -> Configuration<U>? in
switch config.configuration {
case let .keyPathConfiguration(kp, value) where kp == keyPath:
return value
default:
return nil
}
}.first
}
/// Fetches a nested Configuration for a given keypath
public func configuration<U>(forKeypath keyPath: KeyPath<T, U?>) -> Configuration<U>? {
return
configurations.lazy.compactMap { entry in
entry.typedEntry as? TypedEntry<U>
}.compactMap { config -> Configuration<U>? in
switch config.configuration {
case let .keyPathConfigurationOnOptional(kp, value) where kp == keyPath:
return value
default:
return nil
}
}.first
}
/// Fetches the value for the given keypath within this Configuration object
public func value<U>(forKeypath keyPath: KeyPath<T, U>) -> U? {
return
configurations.lazy.compactMap { entry in
entry.typedEntry as? TypedEntry<U>
}.compactMap { entry -> U? in
switch entry.configuration {
case let .keyPath(kp, value) where kp == keyPath:
return value
default:
return nil
}
}.first
}
public func joined(with other: Configuration) -> Configuration {
var new = self
new.configurations.append(contentsOf: other.configurations)
return new
}
mutating private func appendConfiguration<U>(_ config: TypedEntry<U>.Kind,
file: String,
line: Int) {
let entry = TypedEntry(configuration: config, file: file, line: line)
let anyEntry = AnyEntry(entry: entry)
configurations.append(anyEntry)
}
fileprivate struct TypedEntry<U>: CustomDebugStringConvertible {
var configuration: Kind
var file: String
var line: Int
func applied(to target: T) -> T {
var target = target
switch configuration {
case let .keyPath(kp, value):
target[keyPath: kp] = value
case let .keyPathConfiguration(kp, config):
config.apply(on: &target[keyPath: kp])
case let .keyPathConfigurationOnOptional(kp, config):
if let value = target[keyPath: kp] {
target[keyPath: kp] = config.applied(on: value)
}
case .closure(let closure):
target = closure(target)
}
return target
}
var debugDescription: String {
func descForKeyPath(_ kp: PartialKeyPath<T>, _ config: Configuration<U>) -> String {
let configDescription = config.debugDescription
let finalLine =
configDescription
// Indent nested configurations
.components(separatedBy: "\n")
.map { line in
" " + line
}.joined(separator: "\n")
return "\(type(of: kp).valueType) =\n\(finalLine)"
}
var description = ""
description += "\((file as NSString).lastPathComponent) line #\(line): "
switch configuration {
case let .keyPath(kp, value):
description += "\(type(of: kp).valueType) = \(value)"
case let .keyPathConfiguration(kp, config):
description += descForKeyPath(kp, config)
case let .keyPathConfigurationOnOptional(kp, config):
description += descForKeyPath(kp, config)
case .closure:
description += "{ closure }"
}
return description
}
enum Kind {
case keyPath(WritableKeyPath<T, U>, value: U)
case keyPathConfiguration(WritableKeyPath<T, U>, config: Configuration<U>)
case keyPathConfigurationOnOptional(WritableKeyPath<T, U?>, config: Configuration<U>)
case closure((T) -> T)
}
}
fileprivate struct AnyEntry: CustomDebugStringConvertible {
var typedEntry: Any
var isKeypath: Bool
var value: Any?
var debugDescription: String
var file: String
var line: Int
var closure: (T) -> T
init<U>(entry: TypedEntry<U>) {
self.typedEntry = entry
switch entry.configuration {
case let .keyPath(_, value):
isKeypath = true
self.value = value
case .keyPathConfiguration:
isKeypath = true
value = nil
default:
isKeypath = false
value = nil
}
debugDescription = entry.debugDescription
file = entry.file
line = entry.line
closure = entry.applied
}
}
}
extension Configuration {
public func with<U>(_ keyPath: WritableKeyPath<T, U>,
setTo value: U,
file: String = #file,
line: Int = #line) -> Configuration {
var new = self
new.appendConfiguration(.keyPath(keyPath, value: value), file: file, line: line)
return new
}
public func with<U>(_ keyPath: WritableKeyPath<T, U?>,
setTo value: U,
file: String = #file,
line: Int = #line) -> Configuration {
var new = self
new.appendConfiguration(.keyPath(keyPath, value: value), file: file, line: line)
return new
}
public func with<U>(_ keyPath: WritableKeyPath<T, U>,
configuredWith config: Configuration<U>,
file: String = #file,
line: Int = #line) -> Configuration {
var new = self
new.appendConfiguration(.keyPathConfiguration(keyPath, config: config), file: file, line: line)
return new
}
public func with<U>(_ keyPath: WritableKeyPath<T, U?>,
configuredWith config: Configuration<U>,
file: String = #file,
line: Int = #line) -> Configuration {
var new = self
new.appendConfiguration(.keyPathConfigurationOnOptional(keyPath, config: config), file: file, line: line)
return new
}
public func with(closure: @escaping (T) -> T,
file: String = #file,
line: Int = #line) -> Configuration {
var new = self
new.appendConfiguration(TypedEntry<T>.Kind.closure(closure), file: file, line: line)
return new
}
}
// MARK: - Static Configuration Constructors
extension With {
public static func with<T>(_ property: WritableKeyPath<Self, T>,
setTo value: T,
file: String = #file,
line: Int = #line) -> Configuration<Self> {
return Configuration().with(property, setTo: value, file: file, line: line)
}
public static func with<T>(_ property: WritableKeyPath<Self, T?>,
setTo value: T,
file: String = #file,
line: Int = #line) -> Configuration<Self> {
return Configuration().with(property, setTo: value, file: file, line: line)
}
public static func with<T>(_ property: WritableKeyPath<Self, T>,
configuredWith config: Configuration<T>,
file: String = #file,
line: Int = #line) -> Configuration<Self> {
return Configuration().with(property, configuredWith: config, file: file, line: line)
}
public static func with<T>(_ property: WritableKeyPath<Self, T?>,
configuredWith config: Configuration<T>,
file: String = #file,
line: Int = #line) -> Configuration<Self> {
return Configuration().with(property, configuredWith: config, file: file, line: line)
}
public static func with(closure: @escaping (Self) -> Self,
file: String = #file,
line: Int = #line) -> Configuration<Self> {
return Configuration().with(closure: closure, file: file, line: line)
}
}
// MARK: - Reference (class) Type Support
// MARK: Default 'withable's
extension NSObject: With {}
public extension Configurable where Self: AnyObject {
public func configure(with config: Configuration<Self>) {
config.apply(on: self)
}
}
public extension With where Self: AnyObject {
@discardableResult
public func with<T>(_ property: WritableKeyPath<Self, T>,
setTo value: T) -> Self {
return Configuration().with(property, setTo: value).applied(on: self)
}
@discardableResult
public func with<T>(_ property: WritableKeyPath<Self, T?>,
setTo value: T) -> Self {
return Configuration().with(property, setTo: value).applied(on: self)
}
@discardableResult
public func with<T>(_ property: WritableKeyPath<Self, T>,
configuredWith config: Configuration<T>) -> Self {
Configuration()
.with(property, configuredWith: config)
.apply(on: self)
return self
}
@discardableResult
public func with<T>(_ property: WritableKeyPath<Self, T?>,
configuredWith config: Configuration<T>) -> Self {
Configuration()
.with(property, configuredWith: config)
.apply(on: self)
return self
}
@discardableResult
public func with(config: Configuration<Self>) -> Self {
return config.applied(on: self)
}
@discardableResult
public func with(closure: @escaping (Self) -> Void) -> Self {
Configuration<Self>().with(closure: closure).apply(on: self)
return self
}
@discardableResult
public static func with(closure: @escaping (Self) -> Void) -> Configuration<Self> {
return Configuration().with(closure: closure)
}
}
public extension Configuration where T: AnyObject {
public func apply(on target: T) {
_=applied(on: target)
}
public func with(closure: @escaping (T) -> Void) -> Configuration {
return with(closure: { target -> T in closure(target); return target; })
}
}
// MARK: - Value Type Support
public extension Configurable {
mutating public func configure(with config: Configuration<Self>) {
config.apply(on: &self)
}
}
public extension With {
public func with<T>(_ property: WritableKeyPath<Self, T>, setTo value: T) -> Self {
return Configuration().with(property, setTo: value).applied(on: self)
}
public func with<T>(_ property: WritableKeyPath<Self, T?>, setTo value: T) -> Self {
return Configuration().with(property, setTo: value).applied(on: self)
}
public func with<T>(_ property: WritableKeyPath<Self, T>, configuredWith config: Configuration<T>) -> Self {
return Configuration().with(property, configuredWith: config).applied(on: self)
}
public func with<T>(_ property: WritableKeyPath<Self, T?>, configuredWith config: Configuration<T>) -> Self {
return Configuration().with(property, configuredWith: config).applied(on: self)
}
public func with(closure: @escaping (Self) -> Self) -> Self {
return Configuration().with(closure: closure).applied(on: self)
}
public func with(config: Configuration<Self>) -> Self {
return config.applied(on: self)
}
}
// MARK: - Configuration Extraction
public extension With {
public func extractConfig(with block: (ConfigurationExtractor<Self>) -> (ConfigurationExtractor<Self>)) -> Configuration<Self> {
var extractor = ConfigurationExtractor<Self>()
extractor = block(extractor)
return extractor.configuration(with: self)
}
}
public struct ConfigurationExtractor<T> {
private var keyPathExtractors: [(Configuration<T>, T) -> Configuration<T>] = []
init() {
}
public func value<U>(for keyPath: WritableKeyPath<T, U>,
file: String = #file,
line: Int = #line) -> ConfigurationExtractor {
var copy = self
copy.keyPathExtractors.append({ config, target in
config.with(keyPath, setTo: target[keyPath: keyPath], file: file, line: line)
})
return copy
}
public func configuration(with target: T) -> Configuration<T> {
var config = Configuration<T>()
for extractor in keyPathExtractors {
config = extractor(config, target)
}
return config
}
}
import XCTest
import TestWith
class WithTests: XCTestCase {
func testConfigurationObjectsDontShareMutation() {
let target1 = TestClass()
let target2 = TestClass()
let base = TestClass.with(\.string, setTo: "a")
let diverge1 = base.with(\.firstNumber, setTo: 1)
let diverge2 = base.with(\.secondNumber, setTo: 1)
diverge1.apply(on: target1)
diverge2.apply(on: target2)
XCTAssertEqual(target1.string, "a")
XCTAssertEqual(target1.firstNumber, 1)
XCTAssertEqual(target1.secondNumber, 0)
XCTAssertEqual(target2.string, "a")
XCTAssertEqual(target2.firstNumber, 0)
XCTAssertEqual(target2.secondNumber, 1)
}
func testConfigurationAllowsOverwriting() {
let sut = TestClass.with(\.string, setTo: "a").with(\.string, setTo: "b")
XCTAssertEqual(TestClass().with(config: sut).string, "b")
}
func testConfigurationForKeypath() {
let config = TestClass.with(\.nested, configuredWith: TestStruct.with(\.string, setTo: "a"))
XCTAssertEqual(config.configuration(forKeypath: \.nested)?.value(forKeypath: \.string), "a")
XCTAssertNil(config.configuration(forKeypath: \.nested2))
XCTAssertNil(config.configuration(forKeypath: \.nestedOptional))
}
func testConfigurationForOptionalKeypath() {
let config =
TestClass.with(\.nestedOptional, configuredWith: TestStruct.with(\.string, setTo: "a"))
XCTAssertEqual(config.configuration(forKeypath: \.nestedOptional)?.value(forKeypath: \.string), "a")
}
func testValueForKeypath() {
XCTAssertEqual(TestClass.with(\.string, setTo: "a").value(forKeypath: \.string), "a")
XCTAssertNil(TestClass.with(\.secondNumber, setTo: 1).value(forKeypath: \.firstNumber))
}
func testValueForKeypathWithNullableField() {
XCTAssertEqual(TestClass.with(\.string, setTo: nil).value(forKeypath: \.string), .some(.none))
}
func testWithKeypathConfiguredWith() {
XCTAssertEqual(TestClass().with(\.nested, configuredWith: TestStruct.with(\.string, setTo: "a")).nested.string, "a")
}
func testConfigurationJoinedWithOther() {
let config1 = TestClass.with(\.string, setTo: "abc")
let config2 = TestClass.with(\.secondNumber, setTo: 2)
let result = config1.joined(with: config2)
XCTAssertEqual(result.value(forKeypath: \.string), "abc")
XCTAssertEqual(result.value(forKeypath: \.secondNumber), 2)
}
func testConfigRecordsFileNameAndLineNumber() {
#sourceLocation(file: "test.swift", line: 1)
let config =
TestClass
.with(\.string, setTo: "abc")
.with(\.nested,
configuredWith: TestStruct.with(\.string, setTo: "def"))
.with(\.secondNumber, setTo: 2)
#sourceLocation()
let expected = """
Configuration<TestClass> - keypath count: 3
----
test.swift line #3: Optional<String> = Optional("abc")
test.swift line #4: TestStruct =
Configuration<TestStruct> - keypath count: 1
----
test.swift line #5: Optional<String> = Optional("def")
test.swift line #6: Int = 2
"""
XCTAssertEqual(config.debugDescription, expected)
}
func testSanityCheckTestability() {
enum Styling {
enum Label {
static let `default`
= UILabel
.with(\.numberOfLines, setTo: 2)
.with(\.font, setTo: UIFont.systemFont(ofSize: 13))
}
}
XCTAssertEqual(Styling.Label.default.value(forKeypath: \.numberOfLines), 2)
XCTAssert(Styling.Label.default.value(forKeypath: \.font) == UIFont.systemFont(ofSize: 13))
print(Styling.Label.default.debugDescription)
}
}
// MARK: - Class Reference Tests
extension WithTests {
func testWithOnClass() {
XCTAssertEqual(TestClass().with(\.string, setTo: "a").string, "a")
XCTAssertEqual(TestClass().with(\.firstNumber, setTo: 123).firstNumber, 123)
}
func testWithOnClassStatic() {
XCTAssertEqual(TestClass.with(\.string, setTo: "a").applied(on: TestClass()).string, "a")
XCTAssertEqual(TestClass.with(\.firstNumber, setTo: 123).applied(on: TestClass()).firstNumber, 123)
}
func testWithClosureOnClass() {
let target = TestClass()
target.with(closure: { $0.string = "a" })
XCTAssertEqual(target.string, "a")
}
func testWithClosureOnClassStatic() {
let target = TestClass()
let config = TestClass.with(closure: { $0.string = "a" })
config.apply(on: target)
XCTAssertEqual(target.string, "a")
}
func testWithKeyPathConfigClass() {
let target = TestClass()
target.with(\.nested, configuredWith: TestStruct.with(\.string, setTo: "a"))
XCTAssertEqual(target.nested.string, "a")
}
func testWithConfigOnClass() {
let sut =
Configuration<TestClass>()
.with(\.string, setTo: "a")
XCTAssertEqual(TestClass().with(config: sut).string, "a")
}
func testOptionalKeypathWithValueSetToOnClass() {
let target = TestClass()
let string = "abc"
target.with(\.string, setTo: string)
XCTAssertEqual(target.string, string)
}
func testOptionalKeypathWithValueSetToOnClassStatic() {
let target = TestClass()
let string = "abc"
let config = TestClass.with(\.string, setTo: string)
target.with(config: config)
XCTAssertEqual(target.string, string)
}
func testOptionalKeypathWithValueConfiguredWithOnClassInstance() {
let target = TestClass()
let string = "abc"
target.with(\.nestedOptional, configuredWith: TestStruct.with(\.string, setTo: string))
XCTAssertEqual(target.nestedOptional?.string, string)
}
func testOptionalKeypathWithValueConfiguredWithOnClassStatic() {
let target = TestClass()
let string = "abc"
let config = TestClass.with(\.nestedOptional, configuredWith: TestStruct.with(\.string, setTo: string))
target.with(config: config)
XCTAssertEqual(target.nestedOptional?.string, string)
}
}
// MARK: - Struct Tests
extension WithTests {
func testWithOnStruct() {
XCTAssertEqual(TestStruct().with(\.string, setTo: "a").string, "a")
XCTAssertEqual(TestStruct().with(\.firstNumber, setTo: 123).firstNumber, 123)
}
func testWithOnStructStatic() {
XCTAssertEqual(TestStruct.with(\.string, setTo: "a").applied(on: TestStruct()).string, "a")
XCTAssertEqual(TestStruct.with(\.firstNumber, setTo: 123).applied(on: TestStruct()).firstNumber, 123)
}
func testWithConfigOnStruct() {
let sut =
Configuration<TestStruct>()
.with(\.string, setTo: "a")
XCTAssertEqual(TestStruct().with(config: sut).string, "a")
}
func testWithClosureOnStruct() {
let sut = TestStruct().with {
var target = $0;
target.string = "a";
return target;
}
XCTAssertEqual(sut.string, "a")
}
func testWithClosureOnStructStatic() {
let target = TestStruct()
let config = TestStruct.with(closure: {
var target = $0
target.string = "a"
return target
})
XCTAssertEqual(config.applied(on: target).string, "a")
}
func testWithKeyPathConfigStruct() {
var target = TestStruct()
target =
target.with(\.inner, configuredWith: TestInnerStruct.with(\.number, setTo: 123))
XCTAssertEqual(target.inner.number, 123)
}
func testOptionalKeypathWithValueSetToStruct() {
let string = "abc"
let target = TestStruct().with(\.string, setTo: string)
XCTAssertEqual(target.string, string)
}
func testOptionalKeypathWithValueConfiguredWithOnStructInstance() {
let target =
TestStruct()
.with(\.innerOptional, configuredWith: TestInnerStruct.with(\.number, setTo: 123))
XCTAssertEqual(target.innerOptional?.number, 123)
}
func testOptionalKeypathWithValueConfiguredWithOnStructStatic() {
let target =
TestStruct
.with(\.innerOptional, configuredWith: TestInnerStruct.with(\.number, setTo: 123))
.applied(on: TestStruct())
XCTAssertEqual(target.innerOptional?.number, 123)
}
}
// MARK: - Extraction Tests
extension WithTests {
func testExtractConfig() {
let target = TestClass()
target.string = "abc"
target.firstNumber = 123
let config =
target.extractConfig { conf in
conf.value(for: \.string)
.value(for: \.firstNumber)
}
XCTAssertEqual(config.value(forKeypath: \.string), "abc")
XCTAssertEqual(config.value(forKeypath: \.firstNumber), 123)
}
}
private class TestClass {
var string: String?
var firstNumber: Int = 0
var secondNumber: Int = 0
var nested: TestStruct = TestStruct()
var nested2: TestStruct = TestStruct()
var nestedOptional: TestStruct? = TestStruct()
}
private struct TestStruct {
var string: String?
var firstNumber: Int = 0
var inner: TestInnerStruct = TestInnerStruct()
var innerOptional: TestInnerStruct? = TestInnerStruct()
}
private struct TestInnerStruct {
var number: Int = 0
}
extension TestClass: With { }
extension TestStruct: With { }
extension TestInnerStruct: With { }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment