Yesterday, I was discussing initializing UIEdgeInsets
. Developer Adam Sharp cleverly added computed properties to leveraging keypaths by extending the type to adopt ExpressibleByDictionaryLiteral
:
extension UIEdgeInsets: ExpressibleByDictionaryLiteral { public typealias Key = WritableKeyPath<UIEdgeInsets, CGFloat> public typealias Value = CGFloat public init(dictionaryLiteral elements: (WritableKeyPath<UIEdgeInsets, CGFloat>, CGFloat)...) { self = UIEdgeInsets() for (inset, value) in elements { self[keyPath: inset] = value } } }
This approach lets you use a dictionary literal to initialize your type:
let insets: UIEdgeInsets = [\.left: 8] print(insets) // (l: 8.0, r: 0.0, t: 0.0, b: 0.0)
Pop in a few custom properties, specifically horizontal, vertical, and all, and you have a really cute way of initializing edge insets without building a plethora of custom initializers, keeping the API boundary (Thanks Daniel J) nice and compact:
public extension UIEdgeInsets { public var vertical: CGFloat { get { return 0 } // meaningless but not fatal set { (top, bottom) = (newValue, newValue) } } public var horizontal: CGFloat { get { return 0 } // meaningless but not fatal set { (left, right) = (newValue, newValue) } } public var all: CGFloat { get { return 0 } // meaningless but not fatal set { (vertical, horizontal) = (newValue, newValue) } } }
Unfortunately, you must supply a getter: a WriteableKeyPath
is a “key path that supports reading from and writing to the resulting value.” (Emphasis mine.) That’s why I included the silly return 0
statements for each getter. I originally put in a fatal error but that only got me grief because the values were being read before writing.
Incidentally, Swift does not allow you to build a write-only type for compound abstractions like these. Just in case you were thinking of going that way with your code, here’s what you can expect:
With the dictionary-initializable approach, you may use a dictionary literal with as many or as few key paths as you need to fully customize your instance:
let insets2: UIEdgeInsets = [\.vertical: 8, \.horizontal: 20] print(insets2) // (l: 8.0 , r: 20.0, t: 8.0, b: 20.0) let insets3: UIEdgeInsets = [\.all: 8] print(insets3) // (l: 8.0 , r: 8.0, t: 8.0, b: 8.0)
Stephen Celis notes, “The nice thing about key paths are they’re compiler generated code. you can write a single initializer function and get everything for free without having to define one-off enums or initializers every time.”
This approach is generally useful enough that it’s worth abstracting out a little to support dictionary literal initialization for any type with uniformly-typed property members such as CGRect
or CGPoint
. Nate C came up with a very clever approach to do exactly that. Here’s a modified version of his approach:
/// Allows dictionary literal initialization for any /// conforming type that declares `typealias Value`, /// where `Value` refers to a uniform property Type /// that can be set through a keypath-value dictionary /// /// - Example: /// ``` /// extension CGPoint : UniformKeypathInitializable { /// public typealias Value = CGFloat /// } /// /// let p: CGPoint = [\.x: 0, \.y: 20] /// ``` public protocol UniformKeypathInitializable : ExpressibleByDictionaryLiteral { /// Allow zero-argument initializer init() } extension UniformKeypathInitializable { /// Initializes each member of a keypath-value /// dictionary, allowing the type to be initialized /// with a dictionary literal public init(dictionaryLiteral elements: (WritableKeyPath<Self, Value>, Value)...) { self.init() for (property, value) in elements { self[keyPath: property] = value } } }
You provide a typealias for `Value`, which in this case means the type of the values supplied in the dictionary, and the magic happens for you.:
extension UIEdgeInsets: UniformKeypathInitializable { public typealias Value = CGFloat }
That’s all it takes. Add the custom compound properties and you’re good to go.
Interestingly enough, during this process, I came across possibly the most inscrutable Swift error message ever (which I believe is saying something). Here’s one of my early attempts before I found Nate’s solution, and the error it produced:
Gotta love Swift.
Anyway, if there are errors in the post, fixes, improvements, or suggestions (and you know there always will be), let me know. Email, tweet, comment, whatever you like. Thanks as always!
6 Comments
Looks pretty neat when you only want to set part of a value type’s properties and leave the rest to default.
I also kind of like the convenience properties for
UIEdgeInsets.vertical
. It looks like a neat trick.But then again, I’d rather have a convenience initializer for these to ensure boundaries are upheld properly; you can set one of the vertical properties and then overwrite with the
vertical
property or vice versa, and the order will matter. As the saying goes, as long as it’s allowed to write= [\.top: 12, .\vertical: 5]
, someone will write exactly that, and it will probably be future you. 🙂With
init(vertical: CGFloat = 0, horizontal: CGFloat = 0)
, there’s less room for confusion and accidental error. In the end, one of Swift’s superpowers is how little code you need to write to express intent.Cheers,
Christian
Agreeing with Christian: the dictionary is not as nice as a convenience initializer and it allows conflicting key paths.
It is possible to create a convenience initializer for left, right, top, bottom with defaults of 0 for each (which are missing from the standard initializer) by choosing a different argument order than the standard initializer:
import UIKit
extension UIEdgeInsets {
public init(left: CGFloat = 0, right: CGFloat = 0, top: CGFloat = 0, bottom: CGFloat = 0) {
self.init(top: top, left: left, bottom: bottom, right: right)
}
public init(horizontal: CGFloat = 0, vertical: CGFloat = 0) {
self.init(top: vertical, left: horizontal, bottom: vertical, right: horizontal)
}
public init(all: CGFloat) {
self.init(top: all, left: all, bottom: all, right: all)
}
}
let insets1 = UIEdgeInsets(horizontal: 5)
print(insets1)
let insets2 = UIEdgeInsets(left: 3)
print(insets2)
Cheers,
Thorsten
Hi, I didn’t get where did “\.” symbol come from? Is it built-in for the protocol?
I’ve found out that, thanks)
Any particular reason the getter for horizontal/vertical wouldn’t return the average of the two insets on that axis? (I’m assuming you can’t have a failable getter…)
I’ve been playing with Swift and a laser cutter, and find that you never can have too many geometry convenience values. I never rated “minX/midX/maxX” on CGRect highly, and now I have my very own LineSegment with all that, plus “perpendicular at start/end/mid/given-distance”, “control point for bezier curve touching” and all manner of wacky things I never envisioned as useful before.
[…] Better initializers and defaulted arguments […]