├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Package.swift ├── README.md ├── Sources └── Surface │ ├── CALayer+Animate.swift │ ├── CALayer+Shadow.swift │ ├── MotionManager.swift │ ├── Neumorphism.swift │ ├── Presets.swift │ ├── Shadow.swift │ └── Surface.swift ├── Tests ├── LinuxMain.swift └── SurfaceTests │ ├── SurfaceTests.swift │ └── XCTestManifests.swift └── docs_ ├── button.gif └── switch.gif /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Surface", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13), 12 | .watchOS(.v6) 13 | ], 14 | products: [ 15 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 16 | .library( 17 | name: "Surface", 18 | targets: ["Surface"]), 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | // .package(url: /* package url */, from: "1.0.0"), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 26 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 27 | .target( 28 | name: "Surface", 29 | dependencies: []), 30 | .testTarget( 31 | name: "SurfaceTests", 32 | dependencies: ["Surface"]), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Surface [![Swift](https://img.shields.io/badge/swift-5.1-orange.svg?style=flat)](#) 2 | 3 | **Neumorphic** shadow example: 4 | 5 | ```swift 6 | let view = SurfaceView() 7 | view.frame = ... 8 | view.layer.cornerRadius = ... 9 | view.surfaceLayer.shadow = Shadow(preset: .convex1) 10 | view.surfaceLayer.useDeviceMotionToCastShadow = true 11 | addSubview(view) 12 | ``` 13 | 14 | The cast shadows moves accordingly to the device horizontal axis. 15 | 16 | screen 17 | 18 | 19 | **Convex/Concave** shadow example: 20 | 21 | 22 | ```swift 23 | view.backgroundColor = systemBackground() 24 | let surface = SurfaceView() 25 | surface.frame = CGRect(x: 100, y: 100, width: 96, height: 32) 26 | surface.layer.cornerRadius = 16 27 | surface.surfaceLayer.shadow = Shadow(preset: .concave2) 28 | view.addSubview(surface) 29 | 30 | let button = SurfaceView() 31 | button.frame = CGRect(x: 0, y: 0, width: 48, height: 32) 32 | button.layer.cornerRadius = 16 33 | button.surfaceLayer.shadow = Shadow(preset: .convex1) 34 | button.backgroundColor = .white 35 | surface.addSubview(button) 36 | ``` 37 | 38 | screen 39 | -------------------------------------------------------------------------------- /Sources/Surface/CALayer+Animate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // MARK: - Extension 4 | 5 | public extension CALayer { 6 | /// Animation wrapper on CALayer. 7 | func animate() -> _CALayerAnimate { 8 | _CALayerAnimate(layer: self) 9 | } 10 | 11 | func _isShadowAnimationOngoing() -> Bool { 12 | _containerRef._isShadowAnimationOngoing 13 | } 14 | } 15 | 16 | // MARK: - _CALayerAnimate 17 | 18 | @available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) 19 | public class _CALayerAnimate { 20 | private var animations: [String: CAAnimation] 21 | private var duration: CFTimeInterval 22 | private let layer: CALayer 23 | 24 | init(layer: CALayer) { 25 | self.animations = [String: CAAnimation]() 26 | self.duration = 0.25 // second 27 | self.layer = layer 28 | } 29 | 30 | public func shadowOpacity(_ shadowOpacity: Float) -> _CALayerAnimate { 31 | let key = "shadowOpacity" 32 | let animation = CABasicAnimation(keyPath: key) 33 | animation.fromValue = layer.shadowOpacity 34 | animation.toValue = shadowOpacity 35 | animation.isRemovedOnCompletion = true 36 | animation.fillMode = CAMediaTimingFillMode.forwards 37 | animations[key] = animation 38 | return self 39 | } 40 | 41 | public func shadowRadius(_ shadowRadius: CGFloat) -> _CALayerAnimate { 42 | let key = "shadowRadius" 43 | let animation = CABasicAnimation(keyPath: key) 44 | animation.fromValue = layer.shadowRadius 45 | animation.toValue = shadowRadius 46 | animation.isRemovedOnCompletion = true 47 | animation.fillMode = CAMediaTimingFillMode.forwards 48 | animations[key] = animation 49 | return self 50 | } 51 | 52 | public func shadowOffset(_ size: CGSize) -> _CALayerAnimate { 53 | let key = "shadowOffset" 54 | let animation = CABasicAnimation(keyPath: key) 55 | animation.fromValue = NSValue(cgSize: layer.shadowOffset) 56 | animation.toValue = NSValue(cgSize: size) 57 | animation.isRemovedOnCompletion = true 58 | animation.fillMode = CAMediaTimingFillMode.forwards 59 | 60 | animations[key] = animation 61 | return self 62 | } 63 | 64 | public func duration(_ duration: CFTimeInterval) -> _CALayerAnimate { 65 | self.duration = duration 66 | return self 67 | } 68 | 69 | /// Apply the layer animation 70 | public func start() { 71 | layer._containerRef._isShadowAnimationOngoing = true 72 | CATransaction.begin() 73 | for (key, animation) in animations { 74 | animation.duration = duration 75 | layer.removeAnimation(forKey: key) 76 | layer.add(animation, forKey: key) 77 | } 78 | CATransaction.setCompletionBlock { 79 | self.layer._containerRef._isShadowAnimationOngoing = false 80 | } 81 | CATransaction.commit() 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /Sources/Surface/CALayer+Shadow.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import CoreMotion 3 | 4 | // MARK: - Extension 5 | 6 | public extension CALayer { 7 | /// The high-level shadow argument. 8 | var shadow: Shadow { 9 | get { _containerRef.shadow } 10 | set { _containerRef.shadow = newValue } 11 | } 12 | /// Whether it should use the device motion sensor to cast the shadow angle. 13 | var useDeviceMotionToCastShadow: Bool { 14 | get { _containerRef.useDeviceMotionToCastShadow } 15 | set { _containerRef.useDeviceMotionToCastShadow = newValue } 16 | } 17 | 18 | /// The layer shadow exposed as a canonical shadow format. 19 | /// - note: This triggers an implicit layer animation. 20 | var _appliedLayerShadow: CALayer._CanonicalShadowFormat { 21 | get { _containerRef._appliedLayerShadow } 22 | set { _containerRef._appliedLayerShadow = newValue } 23 | } 24 | 25 | /// Re-apply the shadow to this layer. 26 | func setNeedsLayoutShadow() { 27 | _containerRef.shadow = shadow 28 | } 29 | 30 | /// Updates the shadow path. 31 | func _layoutShadowPath() { 32 | guard _containerRef._isShadowPathAutoSizing else { return } 33 | if !_appliedLayerShadow.isVisible { 34 | shadowPath = nil 35 | } else { 36 | shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).cgPath 37 | } 38 | } 39 | } 40 | 41 | // MARK: _CALayerAssociatedContainer 42 | 43 | final class _CALayerAssociatedContainer { 44 | /// A reference to the CALayer. 45 | private weak var _layer: CALayer? 46 | 47 | /// The associated shadow argument. 48 | var shadow = Shadow(lightInterfaceStyleShadow: _ShadowPair(bottom: .zero)) { 49 | didSet { 50 | guard let layer = _layer else { return } 51 | shadow.applyToLayer(layer) 52 | layer.setNeedsLayout() 53 | layer.setNeedsDisplay() 54 | } 55 | } 56 | 57 | var useDeviceMotionToCastShadow: Bool = false { 58 | didSet { 59 | guard let layer = _layer else { return } 60 | if useDeviceMotionToCastShadow { 61 | _MotionManager.shared.registerLayer(layer) 62 | } else { 63 | _MotionManager.shared.deregisterLayer(layer) 64 | } 65 | } 66 | } 67 | 68 | var _isShadowAnimationOngoing: Bool = false 69 | 70 | /// Update the layer shadow animated. 71 | var _appliedLayerShadow: CALayer._CanonicalShadowFormat = .zero { 72 | didSet { 73 | guard let layer = _layer else { return } 74 | layer.animate() 75 | .shadowRadius(_appliedLayerShadow.blur / 2.0) 76 | .shadowOffset(CGSize( 77 | width: _appliedLayerShadow.offset.x, 78 | height: _appliedLayerShadow.offset.y)) 79 | .shadowOpacity(Float(_appliedLayerShadow.alpha)) 80 | .duration(0.166) 81 | .start() 82 | layer.shadowColor = _appliedLayerShadow.color.cgColor 83 | layer.shadowRadius = _appliedLayerShadow.blur / 2.0 84 | layer.shadowOpacity = Float(_appliedLayerShadow.alpha) 85 | layer.shadowOffset = CGSize(width: _appliedLayerShadow.offset.x, height: _appliedLayerShadow.offset.y) 86 | layer._layoutShadowPath() 87 | } 88 | } 89 | 90 | /// Enables automatic shadowPath sizing. 91 | var _isShadowPathAutoSizing = true 92 | 93 | init(layer: CALayer?) { 94 | self._layer = layer 95 | } 96 | } 97 | 98 | extension CALayer { 99 | /// Layer elevation/ animation state. 100 | var _containerRef: _CALayerAssociatedContainer { 101 | get { 102 | typealias C = _CALayerAssociatedContainer 103 | let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC 104 | guard let obj = objc_getAssociatedObject(self, &_associatedContainerKey) as? C else { 105 | let container = _CALayerAssociatedContainer(layer: self) 106 | objc_setAssociatedObject(self, &_associatedContainerKey, container, nonatomic) 107 | return container 108 | } 109 | return obj 110 | } 111 | set(value) { 112 | let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC 113 | objc_setAssociatedObject(self, &_associatedContainerKey, value, nonatomic) 114 | } 115 | } 116 | } 117 | 118 | private var _associatedContainerKey: UInt8 = 0 119 | -------------------------------------------------------------------------------- /Sources/Surface/MotionManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreMotion 3 | import UIKit 4 | 5 | private let _sharedMotionManager: CMMotionManager = CMMotionManager() 6 | 7 | final class _MotionManager { 8 | 9 | private struct WeakLayerRef { weak var layer: CALayer? } 10 | 11 | static let shared = _MotionManager() 12 | 13 | private let _motionManager = CMMotionManager() 14 | private var _displayLink: CADisplayLink? 15 | private let _queue = OperationQueue() 16 | private var _layers: [WeakLayerRef] = [] 17 | private var _angle: CGFloat = 1.0 18 | 19 | func registerLayer(_ layer: CALayer) { 20 | guard _layers.filter({ $0.layer === layer }).isEmpty else { return } 21 | _layers.append(WeakLayerRef(layer: layer)) 22 | _startOrStopGyroUpdates() 23 | } 24 | 25 | func deregisterLayer(_ layer: CALayer) { 26 | _layers = _layers.filter { $0.layer != nil && $0.layer !== layer } 27 | _startOrStopGyroUpdates() 28 | } 29 | 30 | private func _startOrStopGyroUpdates() { 31 | _layers = _layers.filter { $0.layer != nil } 32 | if _layers.isEmpty { 33 | _motionManager.stopDeviceMotionUpdates() 34 | _displayLink = nil 35 | } else { 36 | guard _displayLink == nil else { return } 37 | _displayLink = CADisplayLink(target: self, selector: #selector(_onDisplayLinkFire)) 38 | _displayLink?.add(to: .current, forMode: .default) 39 | _displayLink?.preferredFramesPerSecond = 10 40 | _motionManager.startDeviceMotionUpdates(to: _queue) { [weak self] data, error in 41 | guard let value = self?._motionManager.deviceMotion?.attitude.roll else { return } 42 | self?._angle = CGFloat(value) 43 | } 44 | } 45 | } 46 | 47 | @objc dynamic private func _onDisplayLinkFire() { 48 | let layers = _layers.compactMap { $0.layer } 49 | let angle = max(-1, min(1, _angle * 2)) 50 | for layer in layers { 51 | guard !layer._isShadowAnimationOngoing() else { continue } 52 | layer.shadow = layer.shadow.withAngle(xt: angle, yt: 1) 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Surface/Neumorphism.swift: -------------------------------------------------------------------------------- 1 | // Forked from https://github.com/hirokimu/EMTNeumorphicView 2 | // Used internally as benchmark. 3 | 4 | // The MIT License (MIT) 5 | // 6 | // Copyright (c) 2020 Emotionale (https://www.emotionale.jp/) 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in all 16 | // copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | // SOFTWARE. 25 | // 26 | 27 | import UIKit 28 | 29 | /// `NeumorphicView` is a subclass of UIView and it provides some Neumorphism style design. 30 | /// Access neumorphicLayer. Change effects via its properties. 31 | public class NeumorphicView: UIView, NeumorphicElementProtocol { 32 | /// Change effects via its properties. 33 | public var neumorphicLayer: NeumorphicLayer? { 34 | return layer as? NeumorphicLayer 35 | } 36 | public override class var layerClass: AnyClass { 37 | return NeumorphicLayer.self 38 | } 39 | public override func layoutSubviews() { 40 | super.layoutSubviews() 41 | neumorphicLayer?.update() 42 | } 43 | } 44 | 45 | /// `NeumorphicButton` is a subclass of UIView and it provides some Neumorphism style design. 46 | /// Access neumorphicLayer. Change effects via its properties. 47 | public class NeumorphicButton: UIButton, NeumorphicElementProtocol { 48 | /// Change effects via its properties. 49 | public var neumorphicLayer: NeumorphicLayer? { 50 | return layer as? NeumorphicLayer 51 | } 52 | public override class var layerClass: AnyClass { 53 | return NeumorphicLayer.self 54 | } 55 | public override func layoutSubviews() { 56 | super.layoutSubviews() 57 | neumorphicLayer?.update() 58 | } 59 | public override var isHighlighted: Bool { 60 | didSet { 61 | if oldValue != isHighlighted { 62 | neumorphicLayer?.selected = isHighlighted 63 | } 64 | } 65 | } 66 | public override var isSelected: Bool { 67 | didSet { 68 | if oldValue != isSelected { 69 | neumorphicLayer?.depthType = isSelected ? .concave : .convex 70 | } 71 | } 72 | } 73 | } 74 | 75 | /// `NeumorphicTableCell` is a subclass of UITableViewCell and it provides some 76 | /// Neumorphism style design. 77 | /// Access neumorphicLayer. Change effects via its properties. 78 | public class NeumorphicTableCell: UITableViewCell, NeumorphicElementProtocol { 79 | private var _bg: NeumorphicView? 80 | 81 | override public init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 82 | super.init(style: style, reuseIdentifier: reuseIdentifier) 83 | backgroundColor = UIColor.clear 84 | } 85 | 86 | public required init?(coder: NSCoder) { 87 | fatalError("init(coder:) has not been implemented") 88 | } 89 | /// Change effects via its properties. 90 | public var neumorphicLayer: NeumorphicLayer? { 91 | if _bg == nil { 92 | _bg = NeumorphicView(frame: bounds) 93 | _bg?.neumorphicLayer?.owningView = self 94 | selectedBackgroundView = UIView() 95 | layer.masksToBounds = true 96 | backgroundView = _bg 97 | } 98 | return _bg?.neumorphicLayer 99 | } 100 | public override func layoutSubviews() { 101 | super.layoutSubviews() 102 | neumorphicLayer?.update() 103 | } 104 | public override func setHighlighted(_ highlighted: Bool, animated: Bool) { 105 | super.setHighlighted(highlighted, animated: animated) 106 | neumorphicLayer?.selected = highlighted 107 | } 108 | public override func setSelected(_ selected: Bool, animated: Bool) { 109 | super.setSelected(selected, animated: animated) 110 | neumorphicLayer?.selected = selected 111 | } 112 | public func depthTypeUpdated(to type: NeumorphicLayerDepthType) { 113 | if let l = _bg?.neumorphicLayer { 114 | layer.masksToBounds = l.depthType == .concave 115 | } 116 | } 117 | } 118 | 119 | public protocol NeumorphicElementProtocol : UIView { 120 | var neumorphicLayer: NeumorphicLayer? { get } 121 | func depthTypeUpdated(to type: NeumorphicLayerDepthType) 122 | } 123 | 124 | public extension NeumorphicElementProtocol { 125 | func depthTypeUpdated(to type: NeumorphicLayerDepthType) { } 126 | } 127 | 128 | public enum NeumorphicLayerCornerType: Int { case all, topRow, middleRow, bottomRow } 129 | 130 | public enum NeumorphicLayerDepthType: Int { case concave, convex } 131 | 132 | public class NeumorphicLayer: CALayer { 133 | private var _props: _NeumorphicLayerProps? 134 | public weak var owningView: NeumorphicElementProtocol? 135 | 136 | /// Default is 1. 137 | public var lightShadowOpacity: Float = 1 { 138 | didSet { if oldValue != lightShadowOpacity { setNeedsDisplay() } } 139 | } 140 | 141 | /// Default is 0.3. 142 | public var darkShadowOpacity: Float = 0.3 { 143 | didSet { if oldValue != darkShadowOpacity { setNeedsDisplay() } } 144 | } 145 | 146 | /// Optional. if it is nil (default), elementBackgroundColor will be used as element color. 147 | public var elementColor: CGColor? { 148 | didSet { if oldValue !== elementColor { setNeedsDisplay() } } 149 | } 150 | 151 | private var elementSelectedColor: CGColor? 152 | 153 | /// It will be used as base color for light/shadow. 154 | /// If elementColor is nil, elementBackgroundColor will be used as elementColor. 155 | public var elementBackgroundColor: CGColor = UIColor.white.cgColor { 156 | didSet { if oldValue !== elementBackgroundColor { setNeedsDisplay() } } 157 | } 158 | 159 | public var depthType: NeumorphicLayerDepthType = .convex { 160 | didSet { 161 | if oldValue != depthType { 162 | owningView?.depthTypeUpdated(to: depthType) 163 | setNeedsDisplay() 164 | } 165 | } 166 | } 167 | 168 | /// ".all" is for buttons. ".topRowm" ".middleRow" ".bottomRow" is for table cells. 169 | public var cornerType: NeumorphicLayerCornerType = .all { 170 | didSet { if oldValue != cornerType { setNeedsDisplay() } } 171 | } 172 | 173 | /// Default is 5. 174 | public var elementDepth: CGFloat = 5 { 175 | didSet { if oldValue != elementDepth { setNeedsDisplay() } } 176 | } 177 | 178 | /// Adding a very thin border on the edge of the element. 179 | public var edged: Bool = false { 180 | didSet { if oldValue != edged { setNeedsDisplay() } } 181 | } 182 | 183 | /// If set to true, show element highlight color. Animated. 184 | public var selected: Bool { 185 | get { 186 | return _selected 187 | } 188 | set { 189 | _selected = newValue 190 | let color = elementColor ?? elementBackgroundColor 191 | elementSelectedColor = 192 | UIColor(cgColor: color).getTransformedColor(saturation: 1, brightness: 0.9).cgColor 193 | _colorLayer?.backgroundColor = _selected ? elementSelectedColor : color 194 | } 195 | } 196 | 197 | private var _selected: Bool = false 198 | private var _colorLayer: CALayer? 199 | private var _shadowLayer: _ShadowLayer? 200 | private var _lightLayer: _ShadowLayer? 201 | private var _edgeLayer: _EdgeLayer? 202 | private var _darkSideColor: CGColor = UIColor.black.cgColor 203 | private var _lightSideColor: CGColor = UIColor.white.cgColor 204 | 205 | 206 | // MARK: Build Layers 207 | 208 | public override func display() { 209 | super.display() 210 | update() 211 | } 212 | 213 | public func update() { 214 | // check property update. 215 | let isBoundsUpdated: Bool = _colorLayer?.bounds != bounds 216 | var currentProps = _NeumorphicLayerProps() 217 | currentProps._cornerType = cornerType 218 | currentProps._depthType = depthType 219 | currentProps._edged = edged 220 | currentProps._lightShadowOpacity = lightShadowOpacity 221 | currentProps._darkShadowOpacity = darkShadowOpacity 222 | currentProps._elementColor = elementColor 223 | currentProps._elementBackgroundColor = elementBackgroundColor 224 | currentProps._elementDepth = elementDepth 225 | currentProps._cornerRadius = cornerRadius 226 | let isPropsNotChanged = _props == nil ? true : currentProps == _props! 227 | if !isBoundsUpdated && isPropsNotChanged { 228 | return 229 | } 230 | _props = currentProps 231 | 232 | // generate shadow color. 233 | let color = elementColor ?? elementBackgroundColor 234 | _lightSideColor = UIColor.white.cgColor 235 | _darkSideColor = 236 | UIColor(cgColor: elementBackgroundColor) 237 | .getTransformedColor(saturation: 0.1, brightness: 0).cgColor 238 | 239 | // add sublayers. 240 | if _colorLayer == nil { 241 | _colorLayer = CALayer() 242 | _colorLayer?.cornerCurve = .continuous 243 | _shadowLayer = _ShadowLayer() 244 | _lightLayer = _ShadowLayer() 245 | _edgeLayer = _EdgeLayer() 246 | insertSublayer(_edgeLayer!, at: 0) 247 | insertSublayer(_colorLayer!, at: 0) 248 | insertSublayer(_lightLayer!, at: 0) 249 | insertSublayer(_shadowLayer!, at: 0) 250 | } 251 | _colorLayer?.frame = bounds 252 | _colorLayer?.backgroundColor = _selected ? elementSelectedColor : color 253 | if depthType == .convex { 254 | masksToBounds = false 255 | _colorLayer?.removeFromSuperlayer() 256 | insertSublayer(_colorLayer!, at: 2) 257 | _colorLayer?.masksToBounds = true 258 | _shadowLayer?.masksToBounds = false 259 | _lightLayer?.masksToBounds = false 260 | _edgeLayer?.masksToBounds = false 261 | } 262 | else { 263 | masksToBounds = true 264 | _colorLayer?.removeFromSuperlayer() 265 | insertSublayer(_colorLayer!, at: 0) 266 | _colorLayer?.masksToBounds = true 267 | _shadowLayer?.masksToBounds = true 268 | _lightLayer?.masksToBounds = true 269 | _edgeLayer?.masksToBounds = true 270 | } 271 | 272 | // initialize sublayers. 273 | _shadowLayer?.initialize( 274 | bounds: bounds, mode: .darkSide, props: _props!, color: _darkSideColor) 275 | _lightLayer?.initialize( 276 | bounds: bounds, mode: .lightSide, props: _props!, color: _lightSideColor) 277 | 278 | if currentProps._edged { 279 | _edgeLayer?.initialize(bounds: bounds, props: _props!, color: _lightSideColor) 280 | } 281 | else { 282 | _edgeLayer?.reset() 283 | } 284 | 285 | // set corners and outer mask. 286 | switch cornerType { 287 | case .all: 288 | if depthType == .convex { 289 | _colorLayer?.cornerRadius = cornerRadius 290 | } 291 | case .topRow: 292 | maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner] 293 | if depthType == .convex { 294 | _colorLayer?.cornerRadius = cornerRadius 295 | _colorLayer?.maskedCorners = maskedCorners 296 | applyOuterMask(bounds: bounds, props: _props!) 297 | } 298 | else { 299 | mask = nil 300 | } 301 | case .middleRow: 302 | maskedCorners = [] 303 | if depthType == .convex { 304 | applyOuterMask(bounds: bounds, props: _props!) 305 | } 306 | else { 307 | mask = nil 308 | } 309 | case .bottomRow: 310 | maskedCorners = [.layerMaxXMaxYCorner, .layerMinXMaxYCorner] 311 | if depthType == .convex { 312 | _colorLayer?.cornerRadius = cornerRadius 313 | _colorLayer?.maskedCorners = maskedCorners 314 | applyOuterMask(bounds: bounds, props: _props!) 315 | } 316 | else { 317 | mask = nil 318 | } 319 | } 320 | } 321 | 322 | private func applyOuterMask(bounds: CGRect, props: _NeumorphicLayerProps) { 323 | let shadowRadius = props._elementDepth 324 | let extendWidth = shadowRadius * 2 325 | var maskFrame = CGRect() 326 | switch props._cornerType { 327 | case .all: 328 | return 329 | case .topRow: 330 | maskFrame = CGRect( 331 | x: -extendWidth, 332 | y: -extendWidth, 333 | width: bounds.size.width + extendWidth * 2, 334 | height: bounds.size.height + extendWidth) 335 | case .middleRow: 336 | maskFrame = CGRect( 337 | x: -extendWidth, 338 | y: 0, 339 | width: bounds.size.width + extendWidth * 2, 340 | height: bounds.size.height) 341 | case .bottomRow: 342 | maskFrame = CGRect( 343 | x: -extendWidth, 344 | y: 0, 345 | width: bounds.size.width + extendWidth * 2, 346 | height: bounds.size.height + extendWidth) 347 | } 348 | let maskLayer = CALayer() 349 | maskLayer.frame = maskFrame 350 | maskLayer.backgroundColor = UIColor.white.cgColor 351 | mask = maskLayer 352 | } 353 | } 354 | 355 | // MARK - Private 356 | 357 | fileprivate struct _NeumorphicLayerProps { 358 | var _lightShadowOpacity: Float = 1 359 | var _darkShadowOpacity: Float = 0.3 360 | var _elementColor: CGColor? 361 | var _elementBackgroundColor: CGColor = UIColor.white.cgColor 362 | var _depthType: NeumorphicLayerDepthType = .convex 363 | var _cornerType: NeumorphicLayerCornerType = .all 364 | var _elementDepth: CGFloat = 5 365 | var _edged: Bool = false 366 | var _cornerRadius: CGFloat = 0 367 | 368 | static func == (lhs: _NeumorphicLayerProps, rhs: _NeumorphicLayerProps) -> Bool { 369 | return lhs._lightShadowOpacity == rhs._lightShadowOpacity && 370 | lhs._darkShadowOpacity == rhs._darkShadowOpacity && 371 | lhs._elementColor === rhs._elementColor && 372 | lhs._elementBackgroundColor === rhs._elementBackgroundColor && 373 | lhs._depthType == rhs._depthType && 374 | lhs._cornerType == rhs._cornerType && 375 | lhs._elementDepth == rhs._elementDepth && 376 | lhs._edged == rhs._edged && 377 | lhs._cornerRadius == rhs._cornerRadius 378 | } 379 | } 380 | 381 | fileprivate enum _ShadowLayerMode: Int { case lightSide, darkSide } 382 | 383 | fileprivate class _ShadowLayerBase: CALayer { 384 | static let corners: [NeumorphicLayerCornerType: UIRectCorner] = [ 385 | .all: [.topLeft, .topRight, .bottomLeft, .bottomRight], 386 | .topRow: [.topLeft, .topRight], 387 | .middleRow: [], 388 | .bottomRow: [.bottomLeft, .bottomRight] 389 | ] 390 | func setCorner(props: _NeumorphicLayerProps) { 391 | switch props._cornerType { 392 | case .all: 393 | cornerRadius = props._cornerRadius 394 | maskedCorners = [ 395 | .layerMinXMinYCorner, 396 | .layerMaxXMinYCorner, 397 | .layerMinXMaxYCorner, 398 | .layerMaxXMaxYCorner] 399 | case .topRow: 400 | cornerRadius = props._cornerRadius 401 | maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] 402 | case .middleRow: 403 | cornerRadius = 0 404 | maskedCorners = [] 405 | case .bottomRow: 406 | cornerRadius = props._cornerRadius 407 | maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] 408 | } 409 | } 410 | } 411 | 412 | fileprivate class _ShadowLayer: _ShadowLayerBase { 413 | private var _lightLayer: CALayer? 414 | 415 | func initialize( 416 | bounds: CGRect, 417 | mode: _ShadowLayerMode, 418 | props: _NeumorphicLayerProps, 419 | color: CGColor 420 | ) { 421 | cornerCurve = .continuous 422 | shouldRasterize = true 423 | rasterizationScale = UIScreen.main.scale 424 | if props._depthType == .convex { 425 | applyOuterShadow(bounds: bounds, mode: mode, props: props, color: color) 426 | } 427 | else { // .concave 428 | applyInnerShadow(bounds: bounds, mode: mode, props: props, color: color) 429 | } 430 | } 431 | 432 | func applyOuterShadow( 433 | bounds: CGRect, 434 | mode: _ShadowLayerMode, 435 | props: _NeumorphicLayerProps, 436 | color: CGColor 437 | ) { 438 | _lightLayer?.removeFromSuperlayer() 439 | _lightLayer = nil 440 | 441 | frame = bounds 442 | cornerRadius = 0 443 | maskedCorners = [] 444 | masksToBounds = false 445 | mask = nil 446 | 447 | let shadowCornerRadius = props._cornerType == .middleRow ? 0 : props._cornerRadius 448 | 449 | // prepare shadow parameters. 450 | let shadowRadius = props._elementDepth 451 | let offsetWidth: CGFloat = shadowRadius / 2 452 | let cornerRadii: CGSize = props._cornerRadius <= 0 453 | ? CGSize.zero 454 | : CGSize(width: shadowCornerRadius - offsetWidth, height: shadowCornerRadius - offsetWidth) 455 | 456 | var shadowX: CGFloat = 0 457 | var shadowY: CGFloat = 0 458 | if mode == .lightSide { 459 | shadowY = -offsetWidth 460 | shadowX = -offsetWidth 461 | } 462 | else { 463 | shadowY = offsetWidth 464 | shadowX = offsetWidth 465 | } 466 | 467 | setCorner(props: props) 468 | let corners = _ShadowLayer.corners[props._cornerType]! 469 | let extendHeight = max(props._cornerRadius, shadowCornerRadius) 470 | 471 | // add shadow. 472 | var shadowBounds = bounds 473 | switch props._cornerType { 474 | case .all: 475 | break 476 | case .topRow: 477 | shadowBounds = CGRect( 478 | x: bounds.origin.x, 479 | y: bounds.origin.y, 480 | width: bounds.size.width, 481 | height: bounds.size.height + extendHeight) 482 | case .middleRow: 483 | shadowY = 0 484 | shadowBounds = CGRect( 485 | x: bounds.origin.x, 486 | y: bounds.origin.y - extendHeight, 487 | width: bounds.size.width, 488 | height: bounds.size.height + extendHeight * 2) 489 | case .bottomRow: 490 | shadowBounds = CGRect( 491 | x: bounds.origin.x, 492 | y: bounds.origin.y - extendHeight, 493 | width: bounds.size.width, 494 | height: bounds.size.height + extendHeight) 495 | } 496 | 497 | let path: UIBezierPath = UIBezierPath( 498 | roundedRect: shadowBounds.insetBy(dx: offsetWidth, dy: offsetWidth), 499 | byRoundingCorners: corners, 500 | cornerRadii: cornerRadii) 501 | shadowPath = path.cgPath 502 | shadowColor = color 503 | shadowOffset = CGSize(width: shadowX, height: shadowY) 504 | shadowOpacity = mode == .lightSide ? props._lightShadowOpacity : props._darkShadowOpacity 505 | self.shadowRadius = shadowRadius 506 | } 507 | 508 | func applyInnerShadow( 509 | bounds: CGRect, 510 | mode: _ShadowLayerMode, 511 | props: _NeumorphicLayerProps, 512 | color: CGColor 513 | ) { 514 | let width = bounds.size.width 515 | let height = bounds.size.height 516 | frame = bounds 517 | 518 | // prepare shadow parameters 519 | let shadowRadius = props._elementDepth * 0.75 520 | 521 | let gap: CGFloat = 1 522 | 523 | let cornerRadii: CGSize = CGSize( 524 | width: props._cornerRadius + gap, height: props._cornerRadius + gap) 525 | let cornerRadiusInner = props._cornerRadius - gap 526 | let cornerRadiiInner: CGSize = CGSize( 527 | width: cornerRadiusInner, height: cornerRadiusInner) 528 | var shadowX: CGFloat = 0 529 | var shadowY: CGFloat = 0 530 | var shadowWidth: CGFloat = width 531 | var shadowHeight: CGFloat = height 532 | 533 | setCorner(props: props) 534 | let corners = _ShadowLayer.corners[props._cornerType]! 535 | switch props._cornerType { 536 | case .all: 537 | break 538 | case .topRow: 539 | shadowHeight += shadowRadius * 4 540 | case .middleRow: 541 | if mode == .lightSide { 542 | shadowWidth += shadowRadius * 3 543 | shadowHeight += shadowRadius * 6 544 | shadowY = -(shadowRadius * 3) 545 | shadowX = -(shadowRadius * 3) 546 | } 547 | else { 548 | shadowWidth += shadowRadius * 2 549 | shadowHeight += shadowRadius * 6 550 | shadowY -= (shadowRadius * 3) 551 | } 552 | case .bottomRow: 553 | shadowHeight += shadowRadius * 4 554 | shadowY = -shadowRadius * 4 555 | } 556 | 557 | // add shadow 558 | let shadowBounds = CGRect(x: 0, y: 0, width: shadowWidth, height: shadowHeight) 559 | var path: UIBezierPath 560 | var innerPath: UIBezierPath 561 | 562 | if props._cornerType == .middleRow { 563 | path = UIBezierPath(rect: shadowBounds.insetBy(dx: -gap, dy: -gap)) 564 | innerPath = UIBezierPath(rect: shadowBounds.insetBy(dx: gap, dy: gap)).reversing() 565 | } 566 | else { 567 | path = UIBezierPath(roundedRect:shadowBounds.insetBy(dx: -gap, dy: -gap), 568 | byRoundingCorners: corners, 569 | cornerRadii: cornerRadii) 570 | innerPath = UIBezierPath(roundedRect: shadowBounds.insetBy(dx: gap, dy: gap), 571 | byRoundingCorners: corners, 572 | cornerRadii: cornerRadiiInner).reversing() 573 | } 574 | path.append(innerPath) 575 | 576 | shadowPath = path.cgPath 577 | masksToBounds = true 578 | shadowColor = color 579 | shadowOffset = CGSize(width: shadowX, height: shadowY) 580 | shadowOpacity = mode == .lightSide ? props._lightShadowOpacity : props._darkShadowOpacity 581 | self.shadowRadius = shadowRadius 582 | 583 | if mode == .lightSide { 584 | if _lightLayer == nil { 585 | _lightLayer = CALayer() 586 | addSublayer(_lightLayer!) 587 | } 588 | _lightLayer?.frame = bounds 589 | _lightLayer?.shadowPath = path.cgPath 590 | _lightLayer?.masksToBounds = true 591 | _lightLayer?.shadowColor = shadowColor 592 | _lightLayer?.shadowOffset = CGSize(width: shadowX, height: shadowY) 593 | _lightLayer?.shadowOpacity = props._lightShadowOpacity 594 | _lightLayer?.shadowRadius = shadowRadius 595 | _lightLayer?.shouldRasterize = true 596 | } 597 | 598 | // add mask to shadow. 599 | if props._cornerType == .middleRow { 600 | mask = nil 601 | } 602 | else { 603 | let maskLayer = _GradientMaskLayer() 604 | maskLayer.frame = bounds 605 | maskLayer.cornerType = props._cornerType 606 | maskLayer.shadowLayerMode = mode 607 | maskLayer.shadowCornerRadius = props._cornerRadius 608 | mask = maskLayer 609 | } 610 | } 611 | } 612 | 613 | fileprivate class _EdgeLayer: _ShadowLayerBase { 614 | func initialize(bounds: CGRect, props: _NeumorphicLayerProps, color: CGColor) { 615 | 616 | setCorner(props: props) 617 | let corners = _EdgeLayer.corners[props._cornerType]! 618 | 619 | cornerCurve = .continuous 620 | shouldRasterize = true 621 | frame = bounds 622 | 623 | var shadowY: CGFloat = 0 624 | var path: UIBezierPath 625 | var innerPath: UIBezierPath 626 | let edgeWidth: CGFloat = 0.75 627 | 628 | var edgeBounds = bounds 629 | let cornerRadii: CGSize = CGSize(width: props._cornerRadius, height: props._cornerRadius) 630 | let cornerRadiusEdge = props._cornerRadius - edgeWidth 631 | let cornerRadiiEdge: CGSize = CGSize(width: cornerRadiusEdge, height: cornerRadiusEdge) 632 | 633 | if props._depthType == .convex { 634 | 635 | switch props._cornerType { 636 | case .all: 637 | break 638 | case .topRow: 639 | edgeBounds = CGRect( 640 | x: bounds.origin.x, 641 | y: bounds.origin.y, 642 | width: bounds.size.width, 643 | height: bounds.size.height + 2) 644 | case .middleRow: 645 | edgeBounds = CGRect( 646 | x: bounds.origin.x, 647 | y: bounds.origin.y - 2, 648 | width: bounds.size.width, 649 | height: bounds.size.height + 4) 650 | case .bottomRow: 651 | edgeBounds = CGRect( 652 | x: bounds.origin.x, 653 | y: bounds.origin.y - 2, 654 | width: bounds.size.width, 655 | height: bounds.size.height + 2) 656 | } 657 | 658 | path = UIBezierPath( 659 | roundedRect: edgeBounds, byRoundingCorners: corners, cornerRadii: cornerRadii) 660 | let innerPath = UIBezierPath( 661 | roundedRect: edgeBounds.insetBy(dx: edgeWidth, dy: edgeWidth), 662 | byRoundingCorners: corners, cornerRadii: cornerRadiiEdge).reversing() 663 | path.append(innerPath) 664 | shadowPath = path.cgPath 665 | shadowColor = color 666 | shadowOffset = CGSize.zero 667 | shadowOpacity = min(props._lightShadowOpacity * 1.5, 1) 668 | shadowRadius = 0 669 | } 670 | else { 671 | // shadow size and y position. 672 | if props._depthType == .concave { 673 | switch props._cornerType { 674 | case .all: 675 | break 676 | case .topRow: 677 | edgeBounds.size.height += 2 678 | case .middleRow: 679 | shadowY = -5 680 | edgeBounds.size.height += 10 681 | case .bottomRow: 682 | shadowY = -2 683 | edgeBounds.size.height += 2 684 | } 685 | } 686 | // shadow path. 687 | if props._cornerType == .middleRow { 688 | path = UIBezierPath(rect: edgeBounds) 689 | innerPath = UIBezierPath( 690 | rect: edgeBounds.insetBy(dx: edgeWidth, dy: edgeWidth)).reversing() 691 | } 692 | else { 693 | path = UIBezierPath( 694 | roundedRect: edgeBounds, byRoundingCorners: corners, cornerRadii: cornerRadii) 695 | innerPath = UIBezierPath( 696 | roundedRect: edgeBounds.insetBy(dx: edgeWidth, dy: edgeWidth), 697 | byRoundingCorners: corners, cornerRadii: cornerRadiiEdge).reversing() 698 | } 699 | path.append(innerPath) 700 | shadowPath = path.cgPath 701 | shadowColor = color 702 | shadowOffset = CGSize(width: 0, height: shadowY) 703 | shadowOpacity = min(props._lightShadowOpacity * 1.5, 1) 704 | shadowRadius = 0 705 | } 706 | } 707 | func reset() { 708 | shadowPath = nil 709 | shadowOffset = CGSize.zero 710 | shadowOpacity = 0 711 | frame = CGRect() 712 | } 713 | } 714 | 715 | fileprivate class _GradientMaskLayer: CALayer { 716 | required override init() { 717 | super.init() 718 | needsDisplayOnBoundsChange = true 719 | } 720 | required init?(coder aDecoder: NSCoder) { 721 | super.init(coder: aDecoder) 722 | } 723 | required override init(layer: Any) { 724 | super.init(layer: layer) 725 | } 726 | 727 | var cornerType: NeumorphicLayerCornerType = .all 728 | var shadowLayerMode: _ShadowLayerMode = .lightSide 729 | var shadowCornerRadius: CGFloat = 0 730 | 731 | private func getTopRightCornerRect(size: CGSize, radius: CGFloat) -> CGRect { 732 | return CGRect(x: size.width - radius, y: 0, width: radius, height: radius) 733 | } 734 | private func getBottomLeftCornerRect(size: CGSize, radius: CGFloat) -> CGRect { 735 | return CGRect(x: 0, y: size.height - radius, width: radius, height: radius) 736 | } 737 | 738 | override func draw(in ctx: CGContext) { 739 | let rectTR = getTopRightCornerRect(size: frame.size, radius: shadowCornerRadius) 740 | let rectTR_BR = CGPoint(x: rectTR.maxX, y: rectTR.maxY) 741 | let rectBL = getBottomLeftCornerRect(size: frame.size, radius: shadowCornerRadius) 742 | let rectBL_BR = CGPoint(x: rectBL.maxX, y: rectBL.maxY) 743 | 744 | let color = UIColor.black.cgColor 745 | 746 | guard let gradient = CGGradient( 747 | colorsSpace: CGColorSpaceCreateDeviceRGB(), 748 | colors: [color, UIColor.clear.cgColor] as CFArray, 749 | locations: [0, 1]) else { return } 750 | 751 | if cornerType == .all { 752 | if shadowLayerMode == .lightSide { 753 | if frame.size.width > shadowCornerRadius * 2 && frame.size.height > shadowCornerRadius * 2 { 754 | ctx.setFillColor(color) 755 | ctx.fill(CGRect( 756 | x: shadowCornerRadius, 757 | y: shadowCornerRadius, 758 | width: frame.size.width - shadowCornerRadius, 759 | height: frame.size.height - shadowCornerRadius) 760 | ) 761 | } 762 | ctx.saveGState() 763 | ctx.addRect(rectTR) 764 | ctx.clip() 765 | ctx.drawLinearGradient(gradient, start: rectTR_BR, end: rectTR.origin, options: []) 766 | ctx.restoreGState() 767 | ctx.saveGState() 768 | ctx.addRect(rectBL) 769 | ctx.clip() 770 | ctx.drawLinearGradient(gradient, start: rectBL_BR, end: rectBL.origin, options: []) 771 | ctx.restoreGState() 772 | } 773 | else { 774 | if frame.size.width > shadowCornerRadius * 2 && frame.size.height > shadowCornerRadius * 2 { 775 | ctx.setFillColor(color) 776 | ctx.fill(CGRect( 777 | x: 0, 778 | y: 0, 779 | width: frame.size.width - shadowCornerRadius, 780 | height: frame.size.height - shadowCornerRadius) 781 | ) 782 | } 783 | ctx.saveGState() 784 | ctx.addRect(rectTR) 785 | ctx.clip() 786 | ctx.drawLinearGradient(gradient, start: rectTR.origin, end: rectTR_BR, options: []) 787 | ctx.restoreGState() 788 | ctx.saveGState() 789 | ctx.addRect(rectBL) 790 | ctx.clip() 791 | ctx.drawLinearGradient(gradient, start: rectBL.origin, end: rectBL_BR, options: []) 792 | ctx.restoreGState() 793 | } 794 | } 795 | else if cornerType == .topRow { 796 | if shadowLayerMode == .lightSide { 797 | ctx.setFillColor(color) 798 | ctx.fill(CGRect( 799 | x: frame.size.width - shadowCornerRadius, 800 | y: shadowCornerRadius, 801 | width: frame.size.width, 802 | height: frame.size.height - shadowCornerRadius) 803 | ) 804 | ctx.saveGState() 805 | ctx.addRect(rectTR) 806 | ctx.clip() 807 | ctx.drawLinearGradient(gradient, start: rectTR_BR, end: rectTR.origin, options: []) 808 | ctx.restoreGState() 809 | } 810 | else { 811 | ctx.setFillColor(color) 812 | ctx.fill(CGRect( 813 | x: 0, 814 | y: 0, 815 | width: frame.size.width - shadowCornerRadius, 816 | height: frame.size.height) 817 | ) 818 | ctx.saveGState() 819 | ctx.addRect(rectTR) 820 | ctx.clip() 821 | ctx.drawLinearGradient(gradient, start: rectTR.origin, end: rectTR_BR, options: []) 822 | ctx.restoreGState() 823 | } 824 | } 825 | else if cornerType == .bottomRow { 826 | ctx.setFillColor(color) 827 | if shadowLayerMode == .lightSide { 828 | ctx.fill(CGRect( 829 | x: shadowCornerRadius, 830 | y: 0, 831 | width: frame.size.width - shadowCornerRadius, 832 | height: frame.size.height) 833 | ) 834 | ctx.saveGState() 835 | ctx.addRect(rectBL) 836 | ctx.clip() 837 | ctx.drawLinearGradient(gradient, start: rectBL_BR, end: rectBL.origin, options: []) 838 | ctx.restoreGState() 839 | } 840 | else { 841 | ctx.fill(CGRect( 842 | x: 0, 843 | y: 0, 844 | width: shadowCornerRadius, 845 | height: frame.size.height - shadowCornerRadius) 846 | ) 847 | ctx.saveGState() 848 | ctx.addRect(rectBL) 849 | ctx.clip() 850 | ctx.drawLinearGradient(gradient, start: rectBL.origin, end: rectBL_BR, options: []) 851 | ctx.restoreGState() 852 | } 853 | } 854 | } 855 | } 856 | 857 | // MARK - Extension 858 | 859 | extension UIColor { 860 | public convenience init(RGB: Int) { 861 | var rgb = RGB 862 | rgb = rgb > 0xffffff ? 0xffffff : rgb 863 | let r = CGFloat(rgb >> 16) / 255.0 864 | let g = CGFloat(rgb >> 8 & 0x00ff) / 255.0 865 | let b = CGFloat(rgb & 0x0000ff) / 255.0 866 | self.init(red: r, green: g, blue: b, alpha: 1.0) 867 | } 868 | public func getTransformedColor(saturation: CGFloat, brightness: CGFloat) -> UIColor { 869 | var hsb = getHSBColor() 870 | hsb.s *= saturation 871 | hsb.b *= brightness 872 | if hsb.s > 1 { hsb.s = 1 } 873 | if hsb.b > 1 { hsb.b = 1 } 874 | return hsb.uiColor 875 | } 876 | private func getHSBColor() -> HSBColor { 877 | var h: CGFloat = 0 878 | var s: CGFloat = 0 879 | var b: CGFloat = 0 880 | var a: CGFloat = 0 881 | getHue(&h, saturation: &s, brightness: &b, alpha: &a) 882 | return HSBColor(h: h, s: s, b: b, alpha: a) 883 | } 884 | } 885 | 886 | private struct HSBColor { 887 | var h: CGFloat 888 | var s: CGFloat 889 | var b: CGFloat 890 | var alpha: CGFloat 891 | init(h: CGFloat, s: CGFloat, b: CGFloat, alpha: CGFloat) { 892 | self.h = h 893 | self.s = s 894 | self.b = b 895 | self.alpha = alpha 896 | } 897 | var uiColor: UIColor { 898 | get { 899 | return UIColor(hue: h, saturation: s, brightness: b, alpha: alpha) 900 | } 901 | } 902 | } 903 | -------------------------------------------------------------------------------- /Sources/Surface/Presets.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public enum DepthPreset { 4 | case concave5 5 | case concave4 6 | case concave3 7 | case concave2 8 | case concave1 9 | case none 10 | case convex1 11 | case convex2 12 | case convex3 13 | case convex4 14 | case convex5 15 | 16 | /// Blur value for this depth. 17 | public var blur: CGFloat { 18 | switch self { 19 | case .concave5: return 16 20 | case .concave4: return 8 21 | case .concave3: return 4 22 | case .concave2: return 2 23 | case .concave1: return 1 24 | case .none: return 0 25 | case .convex1: return 1 26 | case .convex2: return 2 27 | case .convex3: return 4 28 | case .convex4: return 8 29 | case .convex5: return 16 30 | } 31 | } 32 | 33 | /// Offset value for this depth. 34 | public var offset: CGFloat { 35 | switch self { 36 | case .concave5: return -8 37 | case .concave4: return -4 38 | case .concave3: return -2 39 | case .concave2: return -1 40 | case .concave1: return -0.5 41 | case .none: return 0 42 | case .convex1: return 0.5 43 | case .convex2: return 1 44 | case .convex3: return 2 45 | case .convex4: return 4 46 | case .convex5: return 8 47 | } 48 | } 49 | } 50 | 51 | public struct Defaults { 52 | public struct Light { 53 | static var systemBackground = UIColor(red:0.90, green:0.90, blue:0.90, alpha:1.0) 54 | static var background = UIColor(red:0.93, green:0.93, blue:0.93, alpha:1.0) 55 | static var bottomShadowOpacity: CGFloat = 0.16 56 | static var topShadowOpacity: CGFloat = 0.5 57 | static var bottomShadowTint: UIColor = .black 58 | static var topShadowTint: UIColor = .white 59 | } 60 | public struct Dark { 61 | static var systemBackground = UIColor(red:0.13, green:0.13, blue:0.15, alpha:1.0) 62 | static var background = UIColor(red:0.10, green:0.10, blue:0.10, alpha:1.0) 63 | static var bottomShadowOpacity: CGFloat = 0.18 64 | static var topShadowOpacity: CGFloat = 0.7 65 | static var bottomShadowTint: UIColor = .black 66 | static var topShadowTint: UIColor = UIColor(red:0.20, green:0.20, blue:0.20, alpha:1.0) 67 | } 68 | } 69 | 70 | /// Default system background color. 71 | public func systemBackground(shouldReactToIntefaceStyleChange: Bool = true) -> UIColor { 72 | let isDarkAppearance = 73 | UIScreen.main.traitCollection.userInterfaceStyle == .dark && shouldReactToIntefaceStyleChange 74 | return isDarkAppearance ? Defaults.Dark.systemBackground : Defaults.Light.systemBackground 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Surface/Shadow.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct Shadow { 4 | /// Default shadow descriptor. 5 | public let lightInterfaceStyleShadow: _ShadowPair 6 | /// Dark shadow descriptor. 7 | public let darkInterfaceStyleShadow: _ShadowPair 8 | /// Whether the shadow should change when the appearance changed. 9 | public let shouldReactToIntefaceStyleChange: Bool 10 | /// Whether the overlay (highlight) shadow should be applied. 11 | public let shouldUseOverlayShadow: Bool 12 | /// Optional — Whether the view surface should be tinted accordingly to the shadow. 13 | public let shouldApplySurfaceBackground: Bool 14 | /// Optional — This dynamically controls background color of the view. 15 | public let lightSurfaceBackground: UIColor? 16 | /// Optional — This dynamically controls background color of the view. 17 | public let darkSurfaceBackground: UIColor? 18 | 19 | private var _horizontalCastAngle: CGFloat = 0 20 | private var _verticalCastAngle: CGFloat = 1 21 | private var _preset: DepthPreset? 22 | 23 | public init( 24 | lightInterfaceStyleShadow: _ShadowPair, 25 | darkInterfaceStyleShadow: _ShadowPair? = nil, 26 | shouldReactToIntefaceStyleChange: Bool = true, 27 | shouldUseOverlayShadow: Bool = true, 28 | shouldApplySurfaceBackground: Bool = true, 29 | lightSurfaceBackground: UIColor? = nil, 30 | darkSurfaceBackground: UIColor? = nil 31 | ) { 32 | self.lightInterfaceStyleShadow = lightInterfaceStyleShadow 33 | self.darkInterfaceStyleShadow = darkInterfaceStyleShadow ?? lightInterfaceStyleShadow 34 | self.shouldReactToIntefaceStyleChange = shouldReactToIntefaceStyleChange 35 | self.shouldUseOverlayShadow = shouldUseOverlayShadow 36 | self.lightSurfaceBackground = lightSurfaceBackground 37 | self.darkSurfaceBackground = darkSurfaceBackground 38 | self.shouldApplySurfaceBackground = shouldApplySurfaceBackground 39 | } 40 | 41 | public init( 42 | preset: DepthPreset, 43 | horizontalCastAngle: CGFloat = 0, 44 | verticalCastAngle: CGFloat = 1, 45 | shouldReactToIntefaceStyleChange: Bool = true, 46 | shouldUseOverlayShadow: Bool = true, 47 | shouldApplySurfaceBackground: Bool = true 48 | ) { 49 | let xt = horizontalCastAngle 50 | let yt = verticalCastAngle 51 | let lightBottomShadow = CALayer._CanonicalShadowFormat( 52 | color: Defaults.Light.bottomShadowTint, 53 | alpha: Defaults.Light.bottomShadowOpacity, 54 | offset: CGPoint(x: xt * preset.offset, y: yt * preset.offset), 55 | blur: preset.blur, 56 | spread: 1) 57 | let lightTopShadow = CALayer._CanonicalShadowFormat( 58 | color: Defaults.Light.topShadowTint, 59 | alpha: Defaults.Light.topShadowOpacity, 60 | offset: CGPoint(x: -xt * preset.offset, y: -yt * preset.offset), 61 | blur: preset.blur, 62 | spread: 1) 63 | let darkBottomShadow = CALayer._CanonicalShadowFormat( 64 | color: Defaults.Dark.bottomShadowTint, 65 | alpha: Defaults.Dark.bottomShadowOpacity, 66 | offset: CGPoint(x: xt * preset.offset, y: yt * preset.offset), 67 | blur: preset.blur, 68 | spread: 1) 69 | let darkTopShadow = CALayer._CanonicalShadowFormat( 70 | color: Defaults.Dark.topShadowTint, 71 | alpha: Defaults.Dark.topShadowOpacity, 72 | offset: CGPoint(x: -xt * preset.offset, y: -yt * preset.offset), 73 | blur: preset.blur, 74 | spread: 1) 75 | 76 | let lightInterfaceStyleShadow = _ShadowPair( 77 | bottom: lightBottomShadow, 78 | top: lightTopShadow) 79 | let darkInterfaceStyleShadow = _ShadowPair( 80 | bottom: darkBottomShadow, 81 | top: darkTopShadow) 82 | 83 | self.init( 84 | lightInterfaceStyleShadow: lightInterfaceStyleShadow, 85 | darkInterfaceStyleShadow: darkInterfaceStyleShadow, 86 | shouldReactToIntefaceStyleChange: shouldReactToIntefaceStyleChange, 87 | shouldUseOverlayShadow: shouldUseOverlayShadow, 88 | lightSurfaceBackground: shouldApplySurfaceBackground ? Defaults.Light.background : nil, 89 | darkSurfaceBackground: shouldApplySurfaceBackground ? Defaults.Dark.background : nil) 90 | _preset = preset 91 | _horizontalCastAngle = xt 92 | _verticalCastAngle = yt 93 | } 94 | 95 | public func applyToLayer(_ layer: CALayer) { 96 | let isDarkAppearance = 97 | UIScreen.main.traitCollection.userInterfaceStyle == .dark && shouldReactToIntefaceStyleChange 98 | let background = isDarkAppearance ? darkSurfaceBackground : lightSurfaceBackground 99 | let shadow = isDarkAppearance ? darkInterfaceStyleShadow : lightInterfaceStyleShadow 100 | 101 | layer._appliedLayerShadow = shadow.bottom 102 | if shouldUseOverlayShadow, let layer = layer as? SurfaceLayer, let top = shadow.top { 103 | layer._appliedOverlayLayerShadow = top 104 | } 105 | if shouldApplySurfaceBackground { 106 | layer.backgroundColor = background?.cgColor 107 | } 108 | } 109 | 110 | public func withAngle(xt: CGFloat, yt: CGFloat) -> Self { 111 | guard let preset = _preset else { return self } 112 | return Shadow( 113 | preset: preset, 114 | horizontalCastAngle: xt, 115 | verticalCastAngle: yt, 116 | shouldReactToIntefaceStyleChange: shouldReactToIntefaceStyleChange, 117 | shouldUseOverlayShadow: shouldUseOverlayShadow, 118 | shouldApplySurfaceBackground: shouldApplySurfaceBackground) 119 | } 120 | 121 | public func withPreset(_ preset: DepthPreset) -> Self { 122 | return Shadow( 123 | preset: preset, 124 | horizontalCastAngle: _horizontalCastAngle, 125 | verticalCastAngle: _verticalCastAngle, 126 | shouldReactToIntefaceStyleChange: shouldReactToIntefaceStyleChange, 127 | shouldUseOverlayShadow: shouldUseOverlayShadow, 128 | shouldApplySurfaceBackground: shouldApplySurfaceBackground) 129 | } 130 | } 131 | 132 | public struct _ShadowPair { 133 | /// The overlay (highlight) shadow. 134 | public let top: CALayer._CanonicalShadowFormat? 135 | /// The main box shadow. 136 | public let bottom: CALayer._CanonicalShadowFormat 137 | 138 | public init(bottom: CALayer._CanonicalShadowFormat, top: CALayer._CanonicalShadowFormat? = nil) { 139 | self.bottom = bottom 140 | self.top = top 141 | } 142 | } 143 | 144 | public extension CALayer { 145 | /// Shadow format (as used in most design tools such as Sketch). 146 | struct _CanonicalShadowFormat { 147 | /// Global constant for no shadows. 148 | static let zero = 149 | _CanonicalShadowFormat(color: .clear, alpha: 0, offset: .zero, blur: 0, spread: 0) 150 | 151 | public var color: UIColor 152 | public var alpha: CGFloat 153 | public var offset: CGPoint 154 | public var blur: CGFloat 155 | public var spread: CGFloat 156 | 157 | /// Whether the shadow should be drawn or not. 158 | public var isVisible: Bool { 159 | self.alpha == 0 160 | } 161 | 162 | public init( 163 | color: UIColor, 164 | alpha: CGFloat = 0.16, 165 | offset: CGPoint, 166 | blur: CGFloat, 167 | spread: CGFloat = 0 168 | ) { 169 | self.color = color 170 | self.alpha = alpha 171 | self.offset = offset 172 | self.blur = blur 173 | self.spread = spread 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/Surface/Surface.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | open class SurfaceView: UIView { 4 | /// Returns its layer properly casted to `SurfaceLayer`. 5 | open var surfaceLayer: SurfaceLayer { layer as! SurfaceLayer } 6 | 7 | /// Returns the class used to create the layer for instances of this class. 8 | open override class var layerClass: AnyClass { 9 | SurfaceLayer.self 10 | } 11 | } 12 | 13 | public final class SurfaceLayer: CALayer { 14 | /// The shadow applied to the overlay layer. 15 | public var _appliedOverlayLayerShadow: CALayer._CanonicalShadowFormat { 16 | get { _overlayLayer._appliedLayerShadow } 17 | set { _overlayLayer._appliedLayerShadow = newValue } 18 | } 19 | 20 | /// The overlay layer adding a second shadow to it. 21 | private lazy var _overlayLayer: CALayer = { 22 | let layer = CALayer() 23 | addSublayer(layer) 24 | return layer 25 | }() 26 | 27 | /// The radius to use when drawing rounded corners for the layer’s background. Animatable. 28 | public override var cornerRadius: CGFloat { 29 | didSet { 30 | setNeedsLayout() 31 | setNeedsDisplay() 32 | } 33 | } 34 | 35 | /// Tells the layer to update its layout. 36 | public override func layoutSublayers() { 37 | _overlayLayer.frame = bounds 38 | _overlayLayer.backgroundColor = backgroundColor 39 | _overlayLayer.cornerRadius = cornerRadius 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SurfaceTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SurfaceTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SurfaceTests/SurfaceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Surface 3 | 4 | final class SurfaceTests: XCTestCase { 5 | func testExample() { 6 | } 7 | 8 | static var allTests = [ 9 | ("testExample", testExample), 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /Tests/SurfaceTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SurfaceTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /docs_/button.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdrone/Surface/a204af2e022b0e9c5df26d0940a226eb738d64f4/docs_/button.gif -------------------------------------------------------------------------------- /docs_/switch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdrone/Surface/a204af2e022b0e9c5df26d0940a226eb738d64f4/docs_/switch.gif --------------------------------------------------------------------------------