1
\$\begingroup\$

Here is a swift port of UIView subclass that I use often in my apps. It allows me to easily set gradients in place of the backgroundColor property, and also makes it very easy to create views that have one, two, or three rounded corners.

I'm not super familiar with the mechanics behind core graphics and drawRect, so maybe some optimizations could be made? I also like to animate views a lot, but changes to the corners or gradient won't animate with UIView.animate. How can I implement that behavior?

//
//  JFStylishView.swift
//  Soapbox
//
//  Created by Joseph Falcone on 6/2/16.
//  Copyright © 2016 Joseph Falcone. All rights reserved.
//

import UIKit

public enum GradientType
{
    case linear
    case radial
}

//private enum BackgroundFillType
public enum BackgroundFillType
{
    case solid
    case gradient
}

@IBDesignable
open class JFStylishView : UIView
{
    // TODO: should all the properties be private?

    // Rounded Corners
    @IBInspectable var cornerRadTL : CGFloat = 0.0
    @IBInspectable var cornerRadTR : CGFloat = 0.0
    @IBInspectable var cornerRadBR : CGFloat = 0.0
    @IBInspectable var cornerRadBL : CGFloat = 0.0

    // Border
    @IBInspectable var borderWidth : CGFloat = 0.0
    @IBInspectable var borderColor = UIColor.clear

    // Colors
    private var trueBackgroundColor = UIColor.clear // The backgroundColor property has to be clear so that the layer doesn't draw behind the clipping area, so we use this to track what the user wants
    private var bgColors : [CGFloat] = [] // array of colors used in drawrect

    // Gradient points
    @IBInspectable private var gradientStart   = CGPoint(x: 0.5, y: 0.0)
    @IBInspectable private var gradientEnd     = CGPoint(x: 0.5, y: 1.0)
    @IBInspectable private var gradientColorStops : [CGFloat] = []

    // Gradient type
    private var gradientType : GradientType = .linear

    // Background Mode
    private var backgroundFillType : BackgroundFillType = .solid

//    var shadowLayer: CAShapeLayer! // Not ready for this yet

    // MARK: Initialization

    public override init(frame: CGRect)
    {
        super.init(frame:frame)
        initStylishStuff()
    }

    required public init?(coder aDecoder: NSCoder)
    {
        super.init(coder:aDecoder)
        initStylishStuff()
    }

    open override func awakeFromNib()
    {
        super.awakeFromNib()
        initStylishStuff()
    }

    open func initStylishStuff()
    {
    }

    // MARK: Color

    private func getFillType() -> BackgroundFillType
    {
        // Rather than keeping a variable for this that gets set everywhere, we'll just use this getter to figure out what type we are using.
        // Of course, if I get sloppy and don't make the unused elements empty when setting another fill parameter, this could produce a bug.

        // RULES
        // If using a gradient, trueBackgroundColor will be clear
        // If using solid, bgColors will be empty
        // If patterns are ever added, the above will be empty

        if(bgColors.count == 0)
        {return .solid}

        if(trueBackgroundColor == UIColor.clear)
        {return .gradient}

        // Default
        return .solid
    }

    override open var backgroundColor: UIColor?
    {
        get
        {
            return trueBackgroundColor
        }
        set
        {
            trueBackgroundColor = newValue!
            super.backgroundColor = UIColor.clear
            bgColors = []
            backgroundFillType = .solid
        }
    }

    // Default is linear, top to bottom
    // startPoint, endPoint should be coordinates of 0.0-1.0
    public func setBackgroundGradient(_ colors:[UIColor], stops:[CGFloat]? = nil, startPoint:CGPoint?=nil, endPoint:CGPoint?=nil, type:GradientType = .linear)
    {
        assert(colors.count > 1, "At least two colors must be specified.")

        // We won't be using the backgroundColor property when drawing a gradient
        //trueBackgroundColor = UIColor.clearColor()
        backgroundColor = UIColor.clear

        // Calculate the stops if they were not specified
        var stops = stops // arguments are immutable, but we can declare a variable with the same name
        if(stops == nil)
        {
            stops = AppocalypseUI.makeLinearColorStops(colors.count)
        }

        // Provide default start and end points if necessary
        gradientType = type
        switch type
        {
            case .linear: // top to bottom
                gradientStart  = startPoint == nil ? CGPoint.zero : startPoint!
                gradientEnd    = endPoint == nil ? CGPoint(x: 0, y: 1.0) : endPoint!
            case .radial: // center to top
                gradientStart  = startPoint == nil ? CGPoint(x: 0.5, y: 0.5) : startPoint!
                gradientEnd    = endPoint == nil ? CGPoint(x: 0.5, y: 0) : endPoint!
        }

        assert(colors.count == stops?.count, "The number of colors and stops must be equal.")

        //bgColorArr = colors
        bgColors = AppocalypseUI.getFloatArrayFromUIColors(colors)
        gradientColorStops = stops!
    }

    // MARK: Drawing

    override open func draw(_ rect: CGRect)
    {
        // Get the current context
        let context = UIGraphicsGetCurrentContext()

        // Make the background gradient
        let baseSpace = CGColorSpaceCreateDeviceRGB();
        let gradient = CGGradient(colorSpace: baseSpace, colorComponents: bgColors, locations: gradientColorStops, count: gradientColorStops.count)
        //let gradient = CGGradient(colorComponentsSpace: baseSpace, components: bgColors, locations: gradientColorStops, count: gradientColorStops.count);

        // Set the border color and stroke
        context?.setLineWidth(borderWidth);
        context?.setStrokeColor(borderColor.cgColor);

        // Fill in the background, inset by the border

        // We do this weird BG Rect calc because a little bit of BG bleeds behind the border when corners are round
        // As a hack, we just inset the bg a little so that it draws under the border
        let bgRect      = borderWidth <= 0 ? bounds : CGRect(x: bounds.origin.x+borderWidth/2  , y: bounds.origin.y+borderWidth/2  , width: bounds.size.width-borderWidth, height: bounds.size.height-borderWidth)
        let borderRect  = CGRect(x: bounds.origin.x+borderWidth/2, y: bounds.origin.y+borderWidth/2, width: bounds.size.width-borderWidth  , height: bounds.size.height-borderWidth)

        let bgPath      = AppocalypseUI.newPathForRoundedRect(bgRect, radiusTL: cornerRadTL, radiusTR: cornerRadTR, radiusBL: cornerRadBL, radiusBR: cornerRadBR)
        let borderPath  = AppocalypseUI.newPathForRoundedRect(borderRect, radiusTL: cornerRadTL, radiusTR: cornerRadTR, radiusBL: cornerRadBL, radiusBR: cornerRadBR)

        context?.strokePath()

        // Background
        context?.saveGState(); // Saves the state from before we clipped to the path
        context?.addPath(bgPath);
        context?.clip(); // Makes the background fill only the path

        switch getFillType()
        {
        case .gradient:
            let gradientStartInPoints   = CGPoint(x: gradientStart.x*bounds.size.width, y: gradientStart.y*bounds.size.height);
            let gradientEndInPoints     = CGPoint(x: gradientEnd.x*bounds.size.width, y: gradientEnd.y*bounds.size.height);
            switch(gradientType)
            {
            case .linear:
                context?.drawLinearGradient(gradient!, start: gradientStartInPoints, end: gradientEndInPoints, options: []); // Draw a vertical gradient

            case .radial:
                // A radial gradient might not fill the layer...first, fill it with the end color
                UIColor(red: bgColors[bgColors.count-4], green: bgColors[bgColors.count-3], blue: bgColors[bgColors.count-2], alpha: bgColors[bgColors.count-1]).setFill()
                context?.addPath(bgPath); // Not sure why I need this...TODO: Investigate
                context?.fillPath()

                let endRadius = hypot(gradientStartInPoints.x-gradientEndInPoints.x, gradientStartInPoints.y-gradientEndInPoints.y)
                context?.drawRadialGradient(gradient!, startCenter: gradientStartInPoints, startRadius: 0, endCenter: gradientStartInPoints, endRadius: endRadius, options: [])
            }
        case .solid:
            trueBackgroundColor.setFill()
            context?.addPath(bgPath); // Not sure why I need this...TODO: Investigate
            context?.fillPath()
        }

        context?.restoreGState(); // Now we are no longer clipped to the path

        // Border
        context?.addPath(borderPath);
        context?.strokePath();
    }

    // MARK: Config

    public func setCornerRadius(_ radius: CGFloat, corners: UIRectCorner = .allCorners)
    {
        if(corners.contains(.allCorners))
        {
            cornerRadBL = radius
            cornerRadBR = radius
            cornerRadTL = radius
            cornerRadTR = radius
            return
        }
        if(corners.contains(.bottomLeft))   {cornerRadBL = radius}
        if(corners.contains(.bottomRight))  {cornerRadBR = radius}
        if(corners.contains(.topLeft))      {cornerRadTL = radius}
        if(corners.contains(.topRight))     {cornerRadTR = radius}
    }
}

Here is the support file needed for the class to compile:

//
//  AppocalypseUI.swift
//  Soapbox
//
//  Created by Joseph Falcone on 6/2/16.
//  Copyright © 2016 Joseph Falcone. All rights reserved.
//

import UIKit

public class AppocalypseUI: NSObject
{
    /// Generates an array of CGFloat values ranging from 0.0-1.0 which represent the color stops in a gradient
    public class func makeLinearColorStops(_ numStops:Int) -> [CGFloat]
    {
        assert(numStops >= 2, "Must have at least two color stops.")

        let stepIncrement = 1.0/Double(numStops-1)
        var returnArr : [CGFloat] = []

        // The first stop is always 0
        returnArr += [0.0]

        for i in 1 ..< numStops-1
        {
            let stepVal = stepIncrement*Double(i)
            let stepFactor = CGFloat(fmod(stepVal, 1.0))
            returnArr += [stepFactor]
        }

        // The last stop is always 1
        returnArr += [1.0]

        // Fini
        return returnArr
    }

    /// Returns the stop colors in an array
    public class func colorsAlongArray(_ colorArr:[UIColor], steps:Int) -> [UIColor]
    {
        let arrCount = colorArr.count
        let stepIncrement = Double(arrCount)/Double(steps)
        var returnArr : [UIColor] = []

        for i in 0..<steps
        {
            let stepVal = stepIncrement*Double(i)
            let stepFactor = CGFloat(fmod(stepVal, 1.0))
            let stepIndex1 = Int(floor(stepVal/1.0))
            var stepIndex2 = Int(ceil(stepVal/1.0))

            if(stepIndex2 > arrCount-1)
                {stepIndex2 = arrCount-1}

            let color1 = colorArr[stepIndex1]
            let color2 = colorArr[stepIndex2]

            let color = colorByInterpolatingColors(color1, color2: color2, factor: stepFactor)
            returnArr += [color]
        }

        return returnArr
    }

    /// Returns a color between two colors on a gradient
    public class func colorByInterpolatingColors(_ color1:UIColor, color2:UIColor, factor:CGFloat) -> UIColor
    {
        /*
        // components is no longer exposed for some reason...
        let startComponent = color1.cgColor.components
        let endComponent = color2.cgColor.components

        let startAlpha = color1.cgColor.alpha
        let endAlpha = color2.cgColor.alpha

        let r = (startComponent?[0])! + ((endComponent?[0])! - (startComponent?[0])!) * factor
        let g = (startComponent?[1])! + ((endComponent?[1])! - (startComponent?[1])!) * factor
        let b = (startComponent?[2])! + ((endComponent?[2])! - (startComponent?[2])!) * factor
        let a = startAlpha + (endAlpha - startAlpha) * factor
        */

        // Instead, we'll convert to a CIColor
        let ci1 = CoreImage.CIColor(color: color1)
        let ci2 = CoreImage.CIColor(color: color2)

        let r = ci1.red     + (ci2.red   - ci1.red)   * factor
        let g = ci1.green   + (ci2.green - ci1.green) * factor
        let b = ci1.blue    + (ci2.blue  - ci1.blue)  * factor
        let a = ci1.alpha   + (ci2.alpha - ci1.alpha) * factor

        return UIColor(red: r, green: g, blue: b, alpha: a)
    }

    /// Returns an array containing the RGBA components of an array of colors
    public class func getFloatArrayFromUIColors(_ colors:[UIColor]) -> [CGFloat]
    {
        var returnArr : [CGFloat] = []
        for color : UIColor in colors
        {
            var red   : CGFloat = 0.0
            var green : CGFloat = 0.0
            var blue  : CGFloat = 0.0
            var alpha : CGFloat = 0.0

            color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
            returnArr += [red, green, blue, alpha]
        }
        return returnArr
    }

    /// Returns a path for a rectangle with rounded corners
    public class func newPathForRoundedRect(_ rect:CGRect, radiusTL radTL:CGFloat, radiusTR radTR:CGFloat, radiusBL radBL:CGFloat, radiusBR radBR:CGFloat, edges:UIRectEdge = .all) -> CGPath
    {
        let retPath = CGMutablePath()

        // Convenience
        let rectL = rect.origin.x
        let rectR = rect.origin.x+rect.size.width
        let rectT = rect.origin.y
        let rectB = rect.origin.y+rect.size.height

        /*
          P2  P3
        P1      P4
        P8      P5
          P7  P6

           1.5π
            *
        π *   * 0
            *
           0.5π

            AT
             *
        AL *   * AR
             *
            AB
        */

        // Starting from the top left arc, move clockwise
        let p1 = CGPoint(x: rectL       , y: rectT+radTL)
        let p2 = CGPoint(x: rectL+radTL , y: rectT)
        let p3 = CGPoint(x: rectR-radTR , y: rectT)
        let p4 = CGPoint(x: rectR       , y: rectT+radTR)
        let p5 = CGPoint(x: rectR       , y: rectB-radBR)
        let p6 = CGPoint(x: rectR-radBR , y: rectB)
        let p7 = CGPoint(x: rectL+radBL , y: rectB)
        let p8 = CGPoint(x: rectL       , y: rectB-radBL)

        let c1 = CGPoint(x: rect.origin.x                   , y: rect.origin.y)
        let c2 = CGPoint(x: rect.origin.x+rect.size.width   , y: rect.origin.y)
        let c3 = CGPoint(x: rect.origin.x+rect.size.width   , y: rect.origin.y+rect.size.height)
        let c4 = CGPoint(x: rect.origin.x                   , y: rect.origin.y+rect.size.height)

        /*
        //let π = M_PI // The shortcut for π is Alt+P
        let aT : CGFloat = CGFloat(M_PI*1.5)
        let aR : CGFloat = 0
        let aB : CGFloat = CGFloat(M_PI_2)
        let aL : CGFloat = CGFloat(M_PI)
        let cTL = CGPoint(x: rect.origin.x                 + radTL/2, y: rect.origin.y                  + radTL/2)
        let cTR = CGPoint(x: rect.origin.x+rect.size.width - radTR/2, y: rect.origin.y                  + radTR/2)
        let cBL = CGPoint(x: rect.origin.x                 + radBL/2, y: rect.origin.y+rect.size.height - radBL/2)
        let cBR = CGPoint(x: rect.origin.x+rect.size.width - radBR/2, y: rect.origin.y+rect.size.height - radBR/2)
        */

        if(edges.contains(.all) || (edges.contains(.left) && edges.contains(.right) && edges.contains(.top) && edges.contains(.bottom)))
        {
            retPath.move(to: p1)
            retPath.addArc(tangent1End: c1, tangent2End: p2, radius: radTL)
            retPath.addLine(to: p3)
            retPath.addArc(tangent1End: c2, tangent2End: p4, radius: radTR)
            retPath.addLine(to: p5)
            retPath.addArc(tangent1End: c3, tangent2End: p6, radius: radBR)
            retPath.addLine(to: p7)
            retPath.addArc(tangent1End: c4, tangent2End: p8, radius: radBL)
            retPath.addLine(to: p1)

            retPath.closeSubpath()
            return retPath
        }

        if(edges.contains(.top))
        {
            retPath.move(to: p1)
            retPath.addArc(tangent1End: c1, tangent2End: p2, radius: radTL)
            retPath.addLine(to: p3)
            retPath.addArc(tangent1End: c2, tangent2End: p4, radius: radTR)
        }

        if(edges.contains(.right))
        {
            retPath.move(to: p3)
            retPath.addArc(tangent1End: c2, tangent2End: p4, radius: radTR)
            retPath.addLine(to: p5)
            retPath.addArc(tangent1End: c3, tangent2End: p6, radius: radBR)
        }

        if(edges.contains(.bottom))
        {
            retPath.move(to: p5)
            retPath.addArc(tangent1End: c3, tangent2End: p6, radius: radBR)
            retPath.addLine(to: p7)
            retPath.addArc(tangent1End: c4, tangent2End: p8, radius: radBL)
        }

        if(edges.contains(.left))
        {
            retPath.move(to: p7)
            retPath.addArc(tangent1End: c4, tangent2End: p8, radius: radBL)
            retPath.addLine(to: p1)
            retPath.addArc(tangent1End: c1, tangent2End: p2, radius: radTL)
        }

        return retPath
    }

    public class func createHorizontalArcPath(_ startPoint:CGPoint, width:CGFloat, arcHeight:CGFloat, closed:Bool = false) -> CGMutablePath
    {
        // http://www.raywenderlich.com/33193/core-graphics-tutorial-arcs-and-paths

        let arcRect = CGRect(x: startPoint.x, y: startPoint.y-arcHeight, width: width, height: arcHeight)

        let arcRadius = (arcRect.size.height/2) + (pow(arcRect.size.width, 2) / (8*arcRect.size.height));
        let arcCenter = CGPoint(x: arcRect.origin.x + arcRect.size.width/2, y: arcRect.origin.y + arcRadius);

        let angle = acos(arcRect.size.width / (2*arcRadius));
        let startAngle = CGFloat(M_PI)+angle // (180 degrees + angle)
        let endAngle = CGFloat(M_PI*2)-angle // (360 degrees - angle)

        let path = CGMutablePath();
        path.addArc(center: arcCenter, radius: arcRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
        if(closed == true)
        {path.addLine(to: startPoint)}
        return path;
    }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.