Skip to content

Commit 2f749a2

Browse files
Mark Pospeselkarthikyml
authored andcommitted
Split offset into x and y values. Clamp blur and opacity inputs. Adjust spread math.
1 parent ffcb770 commit 2f749a2

File tree

2 files changed

+162
-45
lines changed

2 files changed

+162
-45
lines changed

Sources/YCoreUI/Components/Elevation.swift

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,62 +8,108 @@
88

99
import UIKit
1010

11-
/// Encapsulates design shadows and apply them to view.
11+
/// Encapsulates design shadows and applies them to a layer.
12+
///
13+
/// This data structure is designed to closely match how shadows are exported from Figma
14+
/// and also how they are specified for web/CSS.
15+
/// cf. https://www.w3.org/TR/css-backgrounds-3/#box-shadow
1216
public struct Elevation {
13-
/// The offset of the layer’s shadow.
14-
public let offset: CGSize
15-
/// The blur of the layer's shadow.
17+
/// Specifies the horizontal offset of the shadow.
18+
///
19+
/// A positive value draws a shadow that is offset to the right of the box, a negative length to the left.
20+
public let xOffset: CGFloat
21+
22+
/// Specifies the vertical offset of the shadow.
23+
///
24+
/// A positive value offsets the shadow down, a negative one up.
25+
public let yOffset: CGFloat
26+
27+
/// Specifies the blur radius.
28+
///
29+
/// Negative values are invalid. If the blur value is zero, the shadow’s edge is sharp.
30+
/// Otherwise, the larger the value, the more the shadow’s edge is blurred
1631
public let blur: CGFloat
17-
/// The spread of the layer's shadow.
32+
33+
/// Specifies the spread distance.
34+
///
35+
/// Positive values cause the shadow to expand in all directions by the specified radius.
36+
/// Negative values cause the shadow to contract.
1837
public let spread: CGFloat
19-
/// The color of the layer’s shadow.
38+
39+
/// Specifies the color of the shadow.
40+
///
41+
/// This value should be opaque (i.e. alpha channel = 1.0 or 0xFF). `opacity` is a separate property.
2042
public let color: UIColor
21-
/// The opacity of the layer’s shadow.
43+
44+
/// Specifies the opacity of the shadow.
45+
///
46+
/// Possible values range from `0.0` to `1.0` (inclusive). `0` means no shadow, and `1` means fully opaque.
2247
public let opacity: Float
23-
/// Flag to set shadow path. Default is `true`.
48+
49+
/// Whether `apply(layer:cornerRadius:)` should set the shadowPath. Defaults to `true`.
50+
///
51+
/// Should be `false` for layers that are not a simple rect or round-rect
52+
/// (e.g. ellipse, circle, donut, or other irregular shape such as a logo).
53+
/// Note that when `false` the `spread` property is ignored.
54+
/// Image rendering performance will be better when set to `true` (avoids an off-screen render pass).
2455
public let useShadowPath: Bool
2556

26-
/// Initializes `Elevation`.
57+
/// Initializes an elevation
2758
/// - Parameters:
28-
/// - offset: the offset of the layer’s shadow
29-
/// - blur: the blur of the layer's shadow
30-
/// - spread: the spread of the layer's shadow
31-
/// - color: the color of the layer’s shadow
32-
/// - opacity: the opacity of the layer’s shadow
33-
/// - useShadowPath: flag to set shadow path. Default is `true`
59+
/// - xOffset: the horizontal offset of the shadow
60+
/// - yOffset: the vertical offset of the shadow
61+
/// - blur: the blur radius
62+
/// - spread: the spread distance
63+
/// - color: the color of the shadow
64+
/// - opacity: the opacity of the shadow
65+
/// - useShadowPath: whether to set the shadowPath. Default is `true`
3466
public init(
35-
offset: CGSize,
67+
xOffset: CGFloat,
68+
yOffset: CGFloat,
3669
blur: CGFloat,
3770
spread: CGFloat,
3871
color: UIColor,
3972
opacity: Float,
4073
useShadowPath: Bool = true
4174
) {
42-
self.offset = offset
43-
self.blur = blur
75+
self.xOffset = xOffset
76+
self.yOffset = yOffset
77+
self.blur = max(blur, 0)
4478
self.spread = spread
4579
self.color = color
46-
self.opacity = opacity
80+
self.opacity = max(min(opacity, 1), 0)
4781
self.useShadowPath = useShadowPath
4882
}
4983

5084
/// Applies elevation to a layer.
85+
///
86+
/// In most cases `cornerRadius` should match `layer.cornerRadius`.
87+
/// Note: This method does not set `layer.cornerRadius`, only the layer's shadow properties.
5188
/// - Parameters:
52-
/// - layer: layer of the corresponding view
53-
/// - cornerRadius: the radius to use when drawing rounded corners for the layer’s background. Default is `.zero`
54-
public func apply(layer: CALayer, cornerRadius: CGFloat = .zero) {
89+
/// - layer: the layer to apply the elevation to.
90+
/// - cornerRadius: the corner radius to use for the shadow path.
91+
/// Pass `nil` to use `layer.cornerRadius` (the default).
92+
/// Ignored when `useShadowPath == false`
93+
public func apply(layer: CALayer, cornerRadius: CGFloat? = nil) {
5594
applyShadow(layer: layer)
5695

5796
if useShadowPath {
97+
// inset the rect by the spread distance
5898
let rect = layer.bounds.insetBy(dx: -spread, dy: -spread)
59-
layer.shadowPath = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).cgPath
99+
var radius = cornerRadius ?? layer.cornerRadius
100+
if radius > 0 {
101+
// for rounded rects, we need to increase the corner radius by the spread distance
102+
// (but we floor to 0)
103+
radius = max(radius + spread, 0)
104+
}
105+
layer.shadowPath = UIBezierPath(roundedRect: rect, cornerRadius: radius).cgPath
60106
}
61107
}
62108

63109
private func applyShadow(layer: CALayer) {
64110
layer.shadowOpacity = opacity
65111
layer.shadowColor = color.cgColor
66-
layer.shadowOffset = offset
67-
layer.shadowRadius = blur / 2
112+
layer.shadowOffset = CGSize(width: xOffset, height: yOffset)
113+
layer.shadowRadius = blur / 2.5
68114
}
69115
}

Tests/YCoreUITests/Components/ElevationTests.swift

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,49 @@ final class ElevationTests: XCTestCase {
1313
func test_init_deliversGivenValues() {
1414
let sut = makeSUT()
1515

16-
XCTAssertEqual(sut.offset, CGSize(width: 1, height: 1))
16+
XCTAssertEqual(sut.xOffset, 1)
17+
XCTAssertEqual(sut.yOffset, 1)
1718
XCTAssertEqual(sut.blur, 2)
1819
XCTAssertEqual(sut.spread, 3)
1920
XCTAssertEqual(sut.color, .red)
20-
XCTAssertEqual(sut.opacity, 5)
21+
XCTAssertEqual(sut.opacity, 0.5)
2122
XCTAssertEqual(sut.useShadowPath, true)
2223
}
23-
24+
2425
func test_init_defaultValue() {
2526
let sut = Elevation(
26-
offset: CGSize(width: 1, height: 1),
27+
xOffset: 1,
28+
yOffset: 1,
2729
blur: 2,
2830
spread: 3,
2931
color: .red,
3032
opacity: 5
3133
)
32-
34+
3335
XCTAssertEqual(sut.useShadowPath, true)
3436
}
35-
37+
38+
func test_init_negativeBlur() {
39+
let sut = makeSUT(blur: -10)
40+
41+
XCTAssertEqual(sut.blur, 0)
42+
}
43+
44+
func test_init_negativeOpacity() {
45+
let sut = makeSUT(opacity: -1)
46+
47+
XCTAssertEqual(sut.opacity, 0)
48+
}
49+
50+
func test_init_tooLargeOpacity() {
51+
let sut = makeSUT(opacity: 5)
52+
53+
XCTAssertEqual(sut.opacity, 1)
54+
}
55+
3656
func test_apply_doesNotSetShadowPathWhenUseShadowPathIsFalse() {
3757
let sut = makeSUT(useShadowPath: false)
38-
let layer = CALayer()
58+
let layer = makeLayer()
3959

4060
sut.apply(layer: layer)
4161

@@ -44,54 +64,105 @@ final class ElevationTests: XCTestCase {
4464

4565
func test_apply_setsShadowPathWhenUseShadowPathIsTrue() {
4666
let sut = makeSUT(useShadowPath: true)
47-
let layer = CALayer()
48-
67+
let layer = makeLayer()
68+
4969
sut.apply(layer: layer)
50-
70+
5171
XCTAssertNotNil(layer.shadowPath)
5272
}
53-
73+
74+
func test_apply_usesLayerRadiusWhenCornerRadiusIsNil() {
75+
let sut = makeSUT(xOffset: 0, yOffset: 0, spread: 0, useShadowPath: true)
76+
let layer = makeLayer(cornerRadius: 4)
77+
78+
sut.apply(layer: layer)
79+
80+
XCTAssertEqual(layer.shadowPath, UIBezierPath(roundedRect: layer.bounds, cornerRadius: 4).cgPath)
81+
}
82+
83+
func test_apply_usesCornerRadiusWhenNotNil() {
84+
let sut = makeSUT(xOffset: 0, yOffset: 0, spread: 0, useShadowPath: true)
85+
let layer = makeLayer(cornerRadius: 4)
86+
87+
sut.apply(layer: layer, cornerRadius: 6)
88+
89+
XCTAssertEqual(layer.shadowPath, UIBezierPath(roundedRect: layer.bounds, cornerRadius: 6).cgPath)
90+
}
91+
92+
func test_apply_combinesRadiusAndSpread() {
93+
let spread: CGFloat = 8
94+
let radius: CGFloat = 4
95+
let sut = makeSUT(xOffset: 0, yOffset: 0, spread: spread, useShadowPath: true)
96+
let layer = makeLayer(cornerRadius: radius)
97+
98+
sut.apply(layer: layer)
99+
100+
let rect = layer.bounds.insetBy(dx: -spread, dy: -spread)
101+
XCTAssertEqual(layer.shadowPath, UIBezierPath(roundedRect: rect, cornerRadius: radius + spread).cgPath)
102+
}
103+
104+
func test_apply_floorsRadiusAndSpreadToZero() {
105+
let spread: CGFloat = -8
106+
let radius: CGFloat = 4
107+
let sut = makeSUT(xOffset: 0, yOffset: 0, spread: spread, useShadowPath: true)
108+
let layer = makeLayer(cornerRadius: radius)
109+
110+
sut.apply(layer: layer)
111+
112+
let rect = layer.bounds.insetBy(dx: -spread, dy: -spread)
113+
XCTAssertEqual(layer.shadowPath, UIBezierPath(roundedRect: rect, cornerRadius: 0).cgPath)
114+
}
115+
54116
func test_apply_setsShadowPropertiesWhenUseShadowPathIsFalse() {
55117
let sut = makeSUT(useShadowPath: false)
56-
let layer = CALayer()
118+
let layer = makeLayer()
57119

58120
sut.apply(layer: layer)
59121

60122
XCTAssertEqual(layer.shadowOffset, CGSize(width: 1, height: 1))
61123
XCTAssertEqual(layer.shadowColor, UIColor.red.cgColor)
62-
XCTAssertEqual(layer.shadowOpacity, 5)
63-
XCTAssertEqual(layer.shadowRadius, sut.blur / 2)
124+
XCTAssertEqual(layer.shadowOpacity, 0.5)
125+
XCTAssertEqual(layer.shadowRadius, sut.blur / 2.5)
64126
}
65127

66128
func test_apply_setsShadowPropertiesWhenUseShadowPathIsTrue() {
67129
let sut = makeSUT(useShadowPath: true)
68-
let layer = CALayer()
130+
let layer = makeLayer()
69131

70132
sut.apply(layer: layer)
71133

72134
XCTAssertEqual(layer.shadowOffset, CGSize(width: 1, height: 1))
73135
XCTAssertEqual(layer.shadowColor, UIColor.red.cgColor)
74-
XCTAssertEqual(layer.shadowOpacity, 5)
75-
XCTAssertEqual(layer.shadowRadius, sut.blur / 2)
136+
XCTAssertEqual(layer.shadowOpacity, 0.5)
137+
XCTAssertEqual(layer.shadowRadius, sut.blur / 2.5)
76138
}
77139
}
78140

79141
private extension ElevationTests {
80142
func makeSUT(
81-
offset: CGSize = CGSize(width: 1, height: 1),
143+
xOffset: CGFloat = 1,
144+
yOffset: CGFloat = 1,
82145
blur: CGFloat = 2,
83146
spread: CGFloat = 3,
84147
color: UIColor = .red,
85-
opacity: Float = 5,
148+
opacity: Float = 0.5,
86149
useShadowPath: Bool = true
87150
) -> Elevation {
88151
Elevation(
89-
offset: offset,
152+
xOffset: xOffset,
153+
yOffset: yOffset,
90154
blur: blur,
91155
spread: spread,
92156
color: color,
93157
opacity: opacity,
94158
useShadowPath: useShadowPath
95159
)
96160
}
161+
162+
func makeLayer(cornerRadius: CGFloat = 0) -> CALayer {
163+
let layer = CALayer()
164+
layer.bounds = CGRect(origin: .zero, size: CGSize(width: 64, height: 64))
165+
layer.cornerRadius = cornerRadius
166+
return layer
167+
}
97168
}

0 commit comments

Comments
 (0)