Skip to content

Instantly share code, notes, and snippets.

@kaqu
Created May 11, 2019 10:55
Show Gist options
  • Save kaqu/891371755552b49c301eb5c626f2bb93 to your computer and use it in GitHub Desktop.
Save kaqu/891371755552b49c301eb5c626f2bb93 to your computer and use it in GitHub Desktop.
CoconutUIKit
import UIKit
// MARK: Corner rounding
public enum CornerRoundings {
case none
case all(CGFloat)
case topLeft(CGFloat)
case topRight(CGFloat)
case bottomRight(CGFloat)
case bottomLeft(CGFloat)
case top(CGFloat)
case bottom(CGFloat)
case left(CGFloat)
case right(CGFloat)
}
internal extension CornerRoundings {
func path(with rect: CGRect) -> CGPath? {
var rect = rect
if rect.origin.x.isNaN
|| rect.origin.y.isNaN
|| rect.origin.x == .infinity
|| rect.origin.y == .infinity
|| rect.origin.x == -.infinity
|| rect.origin.y == -.infinity {
rect.origin = .zero
} else { /* nothing */ }
switch self {
case .none:
return nil
case let .all(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: UIRectCorner.allCorners,
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .topLeft(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: UIRectCorner.topLeft,
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .topRight(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: UIRectCorner.topRight,
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .bottomRight(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: UIRectCorner.bottomRight,
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .bottomLeft(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: UIRectCorner.bottomLeft,
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .top(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: [UIRectCorner.topLeft,
UIRectCorner.topRight],
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .bottom(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: [UIRectCorner.bottomLeft,
UIRectCorner.bottomRight],
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .left(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: [UIRectCorner.topLeft,
UIRectCorner.bottomLeft],
cornerRadii: CGSize(width: value, height: value)).cgPath
case let .right(value):
return UIBezierPath(roundedRect: rect,
byRoundingCorners: [UIRectCorner.topRight,
UIRectCorner.bottomRight],
cornerRadii: CGSize(width: value, height: value)).cgPath
}
}
}
extension CornerRoundings: ExpressibleByIntegerLiteral {
public typealias IntegerLiteralType = UInt
public init(integerLiteral value: IntegerLiteralType) {
self = .all(CGFloat(value))
}
}
extension CornerRoundings: ExpressibleByFloatLiteral {
public typealias FloatLiteralType = Double
public init(floatLiteral value: FloatLiteralType) {
self = .all(CGFloat(value))
}
}
public protocol RoundableCornersView: UIView {
var cornerRoundings: CornerRoundings { get set }
}
// MARK: View
open class View : UIView, RoundableCornersView {
public var cornerRoundings: CornerRoundings = .none {
didSet { updateCornerRoundings(cornerRoundings) }
}
@inline(__always)
private func updateCornerRoundings(_ roundings: CornerRoundings) {
guard let path = roundings.path(with: bounds) else {
return layer.mask = nil
}
layer.mask = CAShapeLayer().with(path: path)
}
open override func layoutSubviews() {
super.layoutSubviews()
updateCornerRoundings(cornerRoundings)
}
public init() {
super.init(frame: .zero)
self.translatesAutoresizingMaskIntoConstraints = false
}
@available(*, unavailable)
required public init?(coder aDecoder: NSCoder) { fatalError("Unavailable") }
}
// MARK: ShadowViewContainer
public final class ShadowViewContainer<ContentView: RoundableCornersView>: UIView {
public struct Shadow {
var color: CGColor
var offset: CGSize
var opacity: Float
var radius: CGFloat
public init(color: CGColor = UIColor.black.cgColor,
opacity: Float = 0.3,
radius: CGFloat = 4,
offset: CGSize = .init(width: 0, height: 4))
{
self.color = color
self.opacity = opacity
self.radius = radius
self.offset = offset
}
}
private let content: ContentView
public init(_ shadow: Shadow = .init(), content: ContentView) {
self.content = content
super.init(frame: .zero)
self.translatesAutoresizingMaskIntoConstraints = false
setup(content: content)
setup(shadow: shadow)
}
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) { fatalError("Unavailable") }
fileprivate func setup(shadow: Shadow) {
layer.shadowColor = shadow.color
layer.shadowOffset = shadow.offset
layer.shadowOpacity = shadow.opacity
layer.shadowRadius = shadow.radius
layer.masksToBounds = false
clipsToBounds = false
}
fileprivate func setup(content: ContentView) {
isUserInteractionEnabled = content.isUserInteractionEnabled
content.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(content)
NSLayoutConstraint.activate([
content.leftAnchor.constraint(equalTo: self.leftAnchor),
content.rightAnchor.constraint(equalTo: self.rightAnchor),
content.topAnchor.constraint(equalTo: self.topAnchor),
content.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
}
public override func layoutSubviews() {
super.layoutSubviews()
updateShadowPath()
}
private func updateShadowPath() {
layer.shadowPath = content.cornerRoundings.path(with: bounds)
}
public override var intrinsicContentSize: CGSize {
return content.intrinsicContentSize
}
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return content.hitTest(convert(point, to: content), with: event)
}
}
// MARK: Button
open class Button : UIControl & RoundableCornersView {
public var tapAction: (() -> Void)?
public var hapticFeedbackEnabled: Bool = false
private var feedbackGenerator: UISelectionFeedbackGenerator?
private let pressLayer = CAShapeLayer()
public var cornerRoundings: CornerRoundings = .none {
didSet { updateCornerRoundings(cornerRoundings) }
}
public private(set) var isPressed: Bool = false {
didSet {
guard tapAction != nil else { return }
guard isPressed != oldValue else { return }
switch isPressed {
case true:
CALayer.performWithoutAnimation {
pressLayer.opacity = 0.1
}
case false:
pressLayer.opacity = 0
}
}
}
public init() {
super.init(frame: .zero)
setup()
}
@available(*, unavailable)
required public init?(coder aDecoder: NSCoder) { fatalError("Unavailable") }
private func setup() {
translatesAutoresizingMaskIntoConstraints = false
clipsToBounds = true
layer.masksToBounds = true
setupPressEffect()
setupActions()
}
private func setupPressEffect() {
pressLayer.shouldRasterize = true
pressLayer.rasterizationScale = UIScreen.main.scale
pressLayer.fillColor = UIColor.black.cgColor
pressLayer.opacity = 0.0
pressLayer.frame = bounds
layer.addSublayer(pressLayer)
}
private func setupActions() {
addTarget(self, action: #selector(touchDown), for: .touchDown)
addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)
addTarget(self, action: #selector(touchDragEnter), for: .touchDragEnter)
addTarget(self, action: #selector(touchUpOutside), for: .touchUpOutside)
addTarget(self, action: #selector(touchDragExit), for: .touchDragExit)
addTarget(self, action: #selector(touchCancel), for: .touchCancel)
}
@objc private func touchDown() {
isPressed = true
guard hapticFeedbackEnabled else { return }
feedbackGenerator = .init()
feedbackGenerator?.selectionChanged()
feedbackGenerator?.prepare()
}
@objc private func touchUpInside() {
isPressed = false
tapAction?()
guard let feedbackGenerator = feedbackGenerator else { return }
feedbackGenerator.selectionChanged()
}
@objc private func touchDragEnter() {
isPressed = true
guard let feedbackGenerator = feedbackGenerator else { return }
feedbackGenerator.selectionChanged()
feedbackGenerator.prepare()
}
@objc private func touchUpOutside() {
isPressed = false
feedbackGenerator = nil
}
@objc private func touchDragExit() {
isPressed = false
guard let feedbackGenerator = feedbackGenerator else { return }
feedbackGenerator.prepare()
}
@objc private func touchCancel() {
isPressed = false
feedbackGenerator = nil
}
private func updateCornerRoundings(_ roundings: CornerRoundings) {
guard let path = roundings.path(with: bounds) else {
layer.mask = nil
pressLayer.path = nil
return
}
layer.mask = CAShapeLayer().with(path: path)
pressLayer.path = path
}
override open func layoutSubviews() {
super.layoutSubviews()
updateCornerRoundings(cornerRoundings)
}
}
// MARK: CALayer extensions
internal extension CAShapeLayer {
func with(path: CGPath) -> Self {
self.path = path
return self
}
}
internal extension CALayer {
static func performWithoutAnimation(_ action: () -> Void) {
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
action()
CATransaction.commit()
}
}
@kaqu
Copy link
Author

kaqu commented May 11, 2019

Simple example - Button with rounded corners and shadow at the center

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let button: Button = .init()
        button.hapticFeedbackEnabled = true
        button.tapAction = { print("TAP") }
        button.cornerRoundings = 10
        button.backgroundColor = .gray
        let container: ShadowViewContainer<Button> = .init(.init(), content: button)
        view.addSubview(container)
        NSLayoutConstraint.activate([
            container.widthAnchor.constraint(equalToConstant: 200),
            container.heightAnchor.constraint(equalToConstant: 80),
            container.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            container.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment