How can I animate the properties of my custom UIView? - swift

I frequently need to round only two corners in a view, and sometimes need to use gradients. I've found that the common solution of using a CALayerMask is detrimental to performance, so I devised my own solution overriding drawRect(rect: CGRect). It works well, providing an easy way to round some or all corners, draw a border, and use both linear and radial gradient fills, even being able to set color stops for the gradients.
Unfortunately, when I try to animate these properties with UIView.animateWithDuration, my corners, gradients, and borders don't animate. Rather, they look "stretched" in the initial state, then animate to the final state. I've read that this can be solved with CALayer animation, but I'm not quite clear on the nature of the problem. Is there a way I can solve this as the class is now? If not, when is drawRect(rect: CGRect) preferable to drawLayer(layer: CALayer, inContext ctx: CGContext)?
I'm also open to general suggestions on improving this class.
AppocalypseUI.swift (provides support functions for UI operations)
//
// AppocalypseUI.swift
// Soapbox
//
// Created by Joseph Falcone on 6/2/16.
// Copyright © 2016 Joseph Falcone. All rights reserved.
//
import UIKit
class AppocalypseUI: NSObject
{
/// Generates an array of CGFloat values ranging from 0.0-1.0 which represent the color stops in a gradient
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
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
class func colorByInterpolatingColors(color1:UIColor, color2:UIColor, factor:CGFloat) -> UIColor
{
let startComponent = CGColorGetComponents(color1.CGColor)
let endComponent = CGColorGetComponents(color2.CGColor)
let startAlpha = CGColorGetAlpha(color1.CGColor)
let endAlpha = CGColorGetAlpha(color2.CGColor)
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
return UIColor(red: r, green: g, blue: b, alpha: a)
}
/* No longer needed
class func getFloatArrayFromNSNumbers(numbers:[NSNumber]) -> [CGFloat]
{
var returnArr : [CGFloat] = []
for number in numbers
{
returnArr += [CGFloat(number.floatValue)]
}
return returnArr
}
*/
/// Returns an array containing the RGBA components of an array of colors
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)
/*
// This check and backup should probably be implemented later, but it seems to fail when it shouldn't...probably improper use of optionals
if(color?.getRed(&red, green: &green, blue: &blue , alpha: &alpha) == nil)
{
// If for some reason the above function call fails, try this method of getting RGBA instead
let components = CGColorGetComponents(color?.CGColor)
red = components[0]
green = components[1]
blue = components[2]
alpha = components[3]
}
*/
returnArr += [red, green, blue, alpha]
}
return returnArr
}
/// Returns a path for a rectangle with rounded corners
class func newPathForRoundedRect(rect:CGRect, radiusTL radTL:CGFloat, radiusTR radTR:CGFloat, radiusBL radBL:CGFloat, radiusBR radBR:CGFloat, edges:UIRectEdge = .All) -> CGPathRef
{
let retPath = CGPathCreateMutable()
// 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
// Starting from the top left arc, move clockwise
let p1 = CGPointMake(rectL , rectT+radTL)
let p2 = CGPointMake(rectL+radTL, rectT)
let p3 = CGPointMake(rectR-radTR, rectT)
let p4 = CGPointMake(rectR , rectT+radTR)
let p5 = CGPointMake(rectR , rectB-radBR)
let p6 = CGPointMake(rectR-radBR, rectB)
let p7 = CGPointMake(rectL+radBL, rectB)
let p8 = CGPointMake(rectL , rectB-radBL)
let c1 = CGPointMake(rect.origin.x , rect.origin.y)
let c2 = CGPointMake(rect.origin.x+rect.size.width , rect.origin.y)
let c3 = CGPointMake(rect.origin.x+rect.size.width , rect.origin.y+rect.size.height)
let c4 = CGPointMake(rect.origin.x , rect.origin.y+rect.size.height)
if(edges.contains(.All) || (edges.contains(.Left) && edges.contains(.Right) && edges.contains(.Top) && edges.contains(.Bottom)))
{
CGPathMoveToPoint(retPath, nil, p1.x, p1.y)
CGPathAddArcToPoint (retPath, nil, c1.x, c1.y, p2.x, p2.y, radTL)
CGPathAddLineToPoint(retPath, nil, p3.x, p3.y)
CGPathAddArcToPoint (retPath, nil, c2.x, c2.y, p4.x, p4.y, radTR)
CGPathAddLineToPoint(retPath, nil, p5.x, p5.y)
CGPathAddArcToPoint (retPath, nil, c3.x, c3.y, p6.x, p6.y, radBR)
CGPathAddLineToPoint(retPath, nil, p7.x, p7.y)
CGPathAddArcToPoint (retPath, nil, c4.x, c4.y, p8.x, p8.y, radBL)
CGPathAddLineToPoint(retPath, nil, p1.x, p1.y)
CGPathCloseSubpath(retPath)
return retPath
}
if(edges.contains(.Top))
{
CGPathMoveToPoint(retPath, nil, p1.x, p1.y)
CGPathAddArcToPoint (retPath, nil, c1.x, c1.y, p2.x, p2.y, radTL)
CGPathAddLineToPoint(retPath, nil, p3.x, p3.y)
CGPathAddArcToPoint (retPath, nil, c2.x, c2.y, p4.x, p4.y, radTR)
}
if(edges.contains(.Right))
{
CGPathMoveToPoint(retPath, nil, p3.x, p3.y)
CGPathAddArcToPoint (retPath, nil, c2.x, c2.y, p4.x, p4.y, radTR)
CGPathAddLineToPoint(retPath, nil, p5.x, p5.y)
CGPathAddArcToPoint (retPath, nil, c3.x, c3.y, p6.x, p6.y, radBR)
}
if(edges.contains(.Bottom))
{
CGPathMoveToPoint(retPath, nil, p5.x, p5.y)
CGPathAddArcToPoint (retPath, nil, c3.x, c3.y, p6.x, p6.y, radBR)
CGPathAddLineToPoint(retPath, nil, p7.x, p7.y)
CGPathAddArcToPoint (retPath, nil, c4.x, c4.y, p8.x, p8.y, radBL)
}
if(edges.contains(.Left))
{
CGPathMoveToPoint(retPath, nil, p7.x, p7.y)
CGPathAddArcToPoint (retPath, nil, c4.x, c4.y, p8.x, p8.y, radBL)
CGPathAddLineToPoint(retPath, nil, p1.x, p1.y)
CGPathAddArcToPoint (retPath, nil, c1.x, c1.y, p2.x, p2.y, radTL)
}
return retPath
}
}
JFStylishView.swift
//
// JFStylishView.swift
// Soapbox
//
// Created by Joseph Falcone on 6/2/16.
// Copyright © 2016 Joseph Falcone. All rights reserved.
//
import UIKit
enum GradientType
{
case Linear
case Radial
}
private enum BackgroundFillType
{
case Solid
case Gradient
}
class JFStylishView : UIView
{
// Rounded Corners
var cornerTL : CGFloat = 0.0
var cornerTR : CGFloat = 0.0
var cornerBR : CGFloat = 0.0
var cornerBL : CGFloat = 0.0
// Border
var borderWidth : CGFloat = 4.0
var borderColor = UIColor.greenColor()
// Colors
private var trueBackgroundColor = UIColor.clearColor() // 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
private var gradientStart = CGPointMake(0.5, 0.0)
private var gradientEnd = CGPointMake(0.5, 1.0)
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
override init(frame: CGRect)
{
super.init(frame:frame)
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder:aDecoder)
}
override func awakeFromNib()
{
super.awakeFromNib()
}
func initStylishStuff()
{
cornerTL = 0.0
}
// 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.clearColor())
{return .Gradient}
// Default
return .Solid
}
override var backgroundColor: UIColor?
{
get
{
return trueBackgroundColor
}
set
{
trueBackgroundColor = backgroundColor!
super.backgroundColor = UIColor.clearColor()
//bgColorArr = []
bgColors = []
backgroundFillType = .Solid
}
/*
// Property observer - whenever the background color is
didSet
{
bgColorArr = []
bgColors = []
// bgColorArr = [backgroundColor!]
// bgColors = AppocalypseUI.getFloatArrayFromUIColors([backgroundColor!, backgroundColor!])
}
*/
}
// // Convenient...maybe we shouldn't include this?
// func setBackgroundGradient(topColor:UIColor, bottomColor:UIColor)
// {
// bgColorArr = [topColor, bottomColor]
// bgColors = AppocalypseUI.getFloatArrayFromUIColors([topColor, bottomColor])
// }
// Default is linear, top to bottom
// startPoint, endPoint should be coordinates of 0.0-1.0
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()
// 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 ? CGPointZero : startPoint!
gradientEnd = endPoint == nil ? CGPointMake(0, 1.0) : endPoint!
case .Radial: // center to top
gradientStart = startPoint == nil ? CGPointMake(0.5, 0.5) : startPoint!
gradientEnd = endPoint == nil ? CGPointMake(0.5, 0) : endPoint!
}
assert(colors.count == stops?.count, "The number of colors and stops must be equal.")
//bgColorArr = colors
bgColors = AppocalypseUI.getFloatArrayFromUIColors(colors)
gradientColorStops = stops!
}
/*
override func layoutSubviews()
{
super.layoutSubviews()
if shadowLayer == nil
{
shadowLayer = CAShapeLayer()
shadowLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 12).CGPath
//shadowLayer.fillColor = UIColor.whiteColor().CGColor
shadowLayer.fillColor = UIColor.clearColor().CGColor
shadowLayer.shadowColor = UIColor.darkGrayColor().CGColor
shadowLayer.shadowPath = shadowLayer.path
shadowLayer.shadowOffset = CGSize(width: 2.0, height: 2.0)
shadowLayer.shadowOpacity = 0.8
shadowLayer.shadowRadius = 2
//layer.insertSublayer(shadowLayer, atIndex: 0)
layer.insertSublayer(shadowLayer, below: nil) // also works
}
}
*/
// MARK: Drawing
// override func drawLayer(layer: CALayer, inContext ctx: CGContext) {
//
// }
override func drawRect(rect: CGRect)
{
// Get the current context
let context = UIGraphicsGetCurrentContext()
// Make the background gradient
let baseSpace = CGColorSpaceCreateDeviceRGB();
let gradient = CGGradientCreateWithColorComponents(baseSpace, bgColors, gradientColorStops, gradientColorStops.count);
// Set the border color and stroke
CGContextSetLineWidth(context, borderWidth);
CGContextSetStrokeColorWithColor(context, borderColor.CGColor);
// Fill in the background, inset by the border
let bgRect = CGRectMake(bounds.origin.x+borderWidth , bounds.origin.y+borderWidth , bounds.size.width-borderWidth*2, bounds.size.height-borderWidth*2);
let borderRect = CGRectMake(bounds.origin.x+borderWidth/2, bounds.origin.y+borderWidth/2, bounds.size.width-borderWidth , bounds.size.height-borderWidth);
let bgPath = AppocalypseUI.newPathForRoundedRect(bgRect, radiusTL: cornerTL, radiusTR: cornerTR, radiusBL: cornerBL, radiusBR: cornerBR)
let borderPath = AppocalypseUI.newPathForRoundedRect(borderRect, radiusTL: cornerTL, radiusTR: cornerTR, radiusBL: cornerBL, radiusBR: cornerBR)
CGContextStrokePath(context)
// Background
CGContextSaveGState(context); // Saves the state from before we clipped to the path
CGContextAddPath(context, bgPath);
CGContextClip(context); // Makes the background fill only the path
switch getFillType()
{
case .Gradient:
let gradientStartInPoints = CGPointMake(gradientStart.x*bounds.size.width, gradientStart.y*bounds.size.height);
let gradientEndInPoints = CGPointMake(gradientEnd.x*bounds.size.width, gradientEnd.y*bounds.size.height);
switch(gradientType)
{
case .Linear:
CGContextDrawLinearGradient(context, gradient, gradientStartInPoints, gradientEndInPoints, []); // 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()
CGContextAddPath(context, bgPath); // Not sure why I need this...TODO: Investigate
CGContextFillPath(context)
let endRadius = hypot(gradientStartInPoints.x-gradientEndInPoints.x, gradientStartInPoints.y-gradientEndInPoints.y)
CGContextDrawRadialGradient(context, gradient, gradientStartInPoints, 0, gradientStartInPoints, endRadius, [])
}
case .Solid:
trueBackgroundColor.setFill()
CGContextFillPath(context)
}
CGContextRestoreGState(context); // Now we are no longer clipped to the path
// Border
CGContextAddPath(context, borderPath);
CGContextStrokePath(context);
}
// MARK: Convenience
func removeAllSubviews()
{
for view in subviews
{view.removeFromSuperview()}
}
}

You can create a CAGradientLayer, then add mask with only two corner radius to it. Then you can animate the transform using Core Animation, or UIView transform animation also should work if you put the layer in UIView.
CAGradientLayer * rectangleGradient = [CAGradientLayer layer];
rectangleGradient.colors = #[(id)[UIColor greenColor].CGColor, (id)[UIColor orangeColor].CGColor];
rectangleGradient.startPoint = CGPointMake(0.5, 0);
rectangleGradient.endPoint = CGPointMake(0.5, 1);
rectangleMask.path = maskPath;
////The animation
CABasicAnimation * theTransformAnim = [CABasicAnimation animationWithKeyPath:#"transform"];
theTransformAnim.fromValue = [NSValue valueWithCATransform3D:CATransform3DIdentity];;
theTransformAnim.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(2, 2, 1)];;
theTransformAnim.duration = 1;
[rectangleGradient addAnimation:theTransformAnim];

Related

Swift: Make the outline of a UIView sketch-like

I want to make the outlines of a UIView look "wavey" like someone drew them.
I have this example from PowerPoint, which allows to do it (should work with any size and corner radius):
Currently this is what I have:
myView.layer.borderWidth = 10
myView.layer.borderColor = UIColor.blue.cgColor
myView.layer.cornerRadius = 5 // Optional
Thank
You can create "wavy" lines by using a UIBezierPath with a combination of quad-curves, lines, arcs, etc.
We'll start with a simple line, one-quarter of the width of the view:
Our path would consist of:
move to 0,0
add line to 80,0
If we change that to a quad-curve:
Now we're doing:
move to 0,0
add quad-curve to 80,0 with control point 40,40
If we add another quad-curve going the other way:
Now we're doing:
move to 0,0
add quad-curve to 80,0 with control point 40,40
add quad-curve to 160,0 with control point 120,-40
and we can extend that the width of the view:
of course, that doesn't look like your "sketch" target, so let's change the control-point offsets from 40 to 2:
Now it looks a bit more like a hand-draw "sketched" line.
It's too uniform, though, and it's partially outside the bounds of the view, so let's inset it by 8-pts and, instead of four 25% segments, we'll use (for example) five segments of these widths:
0.15, 0.2, 0.2, 0.27, 0.18
If we take the same approach to go down the right-hand side, back across the bottom, and up the left-hand side, we can get this:
Here's some example code to produce that view:
class SketchBorderView: UIView {
let borderLayer: CAShapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.strokeColor = UIColor.blue.cgColor
layer.addSublayer(borderLayer)
backgroundColor = .yellow
}
override func layoutSubviews() {
let incrementVals: [CGFloat] = [
0.15, 0.2, 0.2, 0.27, 0.18,
]
let lineOffsets: [[CGFloat]] = [
[ 1.0, -2.0],
[-1.0, 2.0],
[-1.0, -2.0],
[ 1.0, 2.0],
[ 0.0, -2.0],
]
let pth: UIBezierPath = UIBezierPath()
// inset bounds by 8-pts so we can draw the "wavy border"
// inside our bounds
let r: CGRect = bounds.insetBy(dx: 8.0, dy: 8.0)
var ptDest: CGPoint = .zero
var ptControl: CGPoint = .zero
// start at top-left
ptDest = r.origin
pth.move(to: ptDest)
// we're at top-left
for i in 0..<incrementVals.count {
ptDest.x += r.width * incrementVals[i]
ptDest.y = r.minY + lineOffsets[i][0]
ptControl.x = pth.currentPoint.x + ((ptDest.x - pth.currentPoint.x) * 0.5)
ptControl.y = r.minY + lineOffsets[i][1]
pth.addQuadCurve(to: ptDest, controlPoint: ptControl)
}
// now we're at top-right
for i in 0..<incrementVals.count {
ptDest.y += r.height * incrementVals[i]
ptDest.x = r.maxX + lineOffsets[i][0]
ptControl.y = pth.currentPoint.y + ((ptDest.y - pth.currentPoint.y) * 0.5)
ptControl.x = r.maxX + lineOffsets[i][1]
pth.addQuadCurve(to: ptDest, controlPoint: ptControl)
}
// now we're at bottom-right
for i in 0..<incrementVals.count {
ptDest.x -= r.width * incrementVals[i]
ptDest.y = r.maxY + lineOffsets[i][0]
ptControl.x = pth.currentPoint.x - ((pth.currentPoint.x - ptDest.x) * 0.5)
ptControl.y = r.maxY + lineOffsets[i][1]
pth.addQuadCurve(to: ptDest, controlPoint: ptControl)
}
// now we're at bottom-left
for i in 0..<incrementVals.count {
ptDest.y -= r.height * incrementVals[i]
ptDest.x = r.minX + lineOffsets[i][0]
ptControl.y = pth.currentPoint.y - ((pth.currentPoint.y - ptDest.y) * 0.5)
ptControl.x = r.minX + lineOffsets[i][1]
pth.addQuadCurve(to: ptDest, controlPoint: ptControl)
}
borderLayer.path = pth.cgPath
}
}
and an example controller:
class SketchTestVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let v = SketchBorderView()
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
v.centerXAnchor.constraint(equalTo: g.centerXAnchor),
v.centerYAnchor.constraint(equalTo: g.centerYAnchor),
v.widthAnchor.constraint(equalToConstant: 320.0),
v.heightAnchor.constraint(equalTo: v.widthAnchor),
])
}
}
Using that code, though, we still have too much uniformity, so in actual use we'd want to randomize the number of segments, the widths of the segments, and the control-point offsets.
Of course, to get your "rounded rect" you'd want to add arcs at the corners.
I expect this should get you on your way though.
use this extension to solve the issue
import Foundation
import UIKit
extension UIView {
func dropShadow(scale: Bool = true) {
layer.masksToBounds = false
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.2
layer.shadowOffset = .zero
layer.shadowRadius = 5
layer.shouldRasterize = true
layer.rasterizationScale = scale ? UIScreen.main.scale : 1
}
#IBInspectable
var cornerRadius: CGFloat {
get {
return layer.cornerRadius
}
set {
layer.cornerRadius = newValue
layer.masksToBounds = newValue > 0
}
}
#IBInspectable
var borderWidth: CGFloat {
get {
return layer.borderWidth
}
set {
layer.borderWidth = newValue
}
}
#IBInspectable
var borderColor: UIColor? {
get {
let color = UIColor.init(cgColor: layer.borderColor!) //UIColor.init(CGColor: layer.borderColor!)
return color
}
set {
layer.borderColor = newValue?.cgColor
}
}
#IBInspectable
var shadowRadius: CGFloat {
get {
return layer.shadowRadius
}
set {
layer.shadowRadius = newValue
}
}
#IBInspectable
var shadowOpacity: Float {
get {
return layer.shadowOpacity
}
set {
layer.shadowOpacity = newValue
}
}
#IBInspectable
var shadowOffset: CGSize {
get {
return layer.shadowOffset
}
set {
layer.shadowOffset = newValue
}
}
#IBInspectable
var shadowColor: UIColor? {
get {
if let color = layer.shadowColor {
return UIColor(cgColor: color)
}
return nil
}
set {
if let color = newValue {
layer.shadowColor = color.cgColor
} else {
layer.shadowColor = nil
}
}
}
}
it will look like this

Cropping is not working perfectly as per the frame drawn

I am trying to crop a selected portion of NSImage which is fitted as per ProportionallyUpOrDown(AspectFill) Mode.
I am drawing a frame using mouse dragged event like this:
class CropImageView: NSImageView {
var startPoint: NSPoint!
var shapeLayer: CAShapeLayer!
var flagCheck = false
var finalPoint: NSPoint!
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
}
override var image: NSImage? {
set {
self.layer = CALayer()
self.layer?.contentsGravity = kCAGravityResizeAspectFill
self.layer?.contents = newValue
self.wantsLayer = true
super.image = newValue
}
get {
return super.image
}
}
override func mouseDown(with event: NSEvent) {
self.startPoint = self.convert(event.locationInWindow, from: nil)
if self.shapeLayer != nil {
self.shapeLayer.removeFromSuperlayer()
self.shapeLayer = nil
}
self.flagCheck = true
var pixelColor: NSColor = NSReadPixel(startPoint) ?? NSColor()
shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = 1.0
shapeLayer.fillColor = NSColor.clear.cgColor
if pixelColor == NSColor.black {
pixelColor = NSColor.color_white
} else {
pixelColor = NSColor.black
}
shapeLayer.strokeColor = pixelColor.cgColor
shapeLayer.lineDashPattern = [1]
self.layer?.addSublayer(shapeLayer)
var dashAnimation = CABasicAnimation()
dashAnimation = CABasicAnimation(keyPath: "lineDashPhase")
dashAnimation.duration = 0.75
dashAnimation.fromValue = 0.0
dashAnimation.toValue = 15.0
dashAnimation.repeatCount = 0.0
shapeLayer.add(dashAnimation, forKey: "linePhase")
}
override func mouseDragged(with event: NSEvent) {
let point: NSPoint = self.convert(event.locationInWindow, from: nil)
var newPoint: CGPoint = self.startPoint
let xDiff = point.x - self.startPoint.x
let yDiff = point.y - self.startPoint.y
let dist = min(abs(xDiff), abs(yDiff))
newPoint.x += xDiff > 0 ? dist : -dist
newPoint.y += yDiff > 0 ? dist : -dist
let path = CGMutablePath()
path.move(to: self.startPoint)
path.addLine(to: NSPoint(x: self.startPoint.x, y: newPoint.y))
path.addLine(to: newPoint)
path.addLine(to: NSPoint(x: newPoint.x, y: self.startPoint.y))
path.closeSubpath()
self.shapeLayer.path = path
}
override func mouseUp(with event: NSEvent) {
self.finalPoint = self.convert(event.locationInWindow, from: nil)
}
}
and selected this area as shown in picture using black dotted line:
My Cropping Code logic is this:
// resize Image Methods
extension CropProfileView {
func resizeImage(image: NSImage) -> Data {
var scalingFactor: CGFloat = 0.0
if image.size.width >= image.size.height {
scalingFactor = image.size.width/cropImgView.size.width
} else {
scalingFactor = image.size.height/cropImgView.size.height
}
let width = (self.cropImgView.finalPoint.x - self.cropImgView.startPoint.x) * scalingFactor
let height = (self.cropImgView.startPoint.y - self.cropImgView.finalPoint.y) * scalingFactor
let xPos = ((image.size.width/2) - (cropImgView.bounds.midX - self.cropImgView.startPoint.x) * scalingFactor)
let yPos = ((image.size.height/2) - (cropImgView.bounds.midY - (cropImgView.size.height - self.cropImgView.startPoint.y)) * scalingFactor)
var croppedRect: NSRect = NSRect(x: xPos, y: yPos, width: width, height: height)
let imageRef = image.cgImage(forProposedRect: &croppedRect, context: nil, hints: nil)
guard let croppedImage = imageRef?.cropping(to: croppedRect) else {return Data()}
let imageWithNewSize = NSImage(cgImage: croppedImage, size: NSSize(width: width, height: height))
guard let data = imageWithNewSize.tiffRepresentation,
let rep = NSBitmapImageRep(data: data),
let imgData = rep.representation(using: .png, properties: [.compressionFactor: NSNumber(floatLiteral: 0.25)]) else {
return imageWithNewSize.tiffRepresentation ?? Data()
}
return imgData
}
}
With this cropping logic i am getting this output:
I think as image is AspectFill thats why its not getting cropped in perfect size as per selected frame. Here if you look at output: xpositon & width & heights are not perfect. Or probably i am not calculating these co-ordinates properly. Let me know the faults probably i am calculating someting wrong.
Note: the CropImageView class in the question is a subclass of NSImageView but the view is layer-hosting and the image is drawn by the layer, not by NSImageView. imageScaling is not used.
When deciding which scaling factor to use you have to take the size of the image view into account. If the image size is width:120, height:100 and the image view size is width:120, height 80 then image.size.width >= image.size.height is true and image.size.width/cropImgView.size.width is 1 but the image is scaled because image.size.height/cropImgView.size.height is 1.25. Calculate the horizontal and vertical scaling factors and use the largest.
See How to crop a UIImageView to a new UIImage in 'aspect fill' mode?
Here's the calculation of croppedRect assuming cropImgView.size returns self.layer!.bounds.size.
var scalingWidthFactor: CGFloat = image.size.width/cropImgView.size.width
var scalingHeightFactor: CGFloat = image.size.height/cropImgView.size.height
var xOffset: CGFloat = 0
var yOffset: CGFloat = 0
switch cropImgView.layer?.contentsGravity {
case CALayerContentsGravity.resize: break
case CALayerContentsGravity.resizeAspect:
if scalingWidthFactor > scalingHeightFactor {
scalingHeightFactor = scalingWidthFactor
yOffset = (cropImgView.size.height - (image.size.height / scalingHeightFactor)) / 2
}
else {
scalingWidthFactor = scalingHeightFactor
xOffset = (cropImgView.size.width - (image.size.width / scalingWidthFactor)) / 2
}
case CALayerContentsGravity.resizeAspectFill:
if scalingWidthFactor < scalingHeightFactor {
scalingHeightFactor = scalingWidthFactor
yOffset = (cropImgView.size.height - (image.size.height / scalingHeightFactor)) / 2
}
else {
scalingWidthFactor = scalingHeightFactor
xOffset = (cropImgView.size.width - (image.size.width / scalingWidthFactor)) / 2
}
default:
print("contentsGravity \(String(describing: cropImgView.layer?.contentsGravity)) is not supported")
return nil
}
let width = (self.cropImgView.finalPoint.x - self.cropImgView.startPoint.x) * scalingWidthFactor
let height = (self.cropImgView.startPoint.y - self.cropImgView.finalPoint.y) * scalingHeightFactor
let xPos = (self.cropImgView.startPoint.x - xOffset) * scalingWidthFactor
let yPos = (cropImgView.size.height - self.cropImgView.startPoint.y - yOffset) * scalingHeightFactor
var croppedRect: NSRect = NSRect(x: xPos, y: yPos, width: width, height: height)
Bugfix: cropImgView.finalPoint should be the corner of the selection, not the location of mouseUp. In CropImageView set self.finalPoint = newPoint in mouseDragged instead of mouseUp.

Color animation

I've written simple animations for drawing rectangles in lines, we can treat them as a bars.
Each bar is one shape layer which has a path which animates ( size change and fill color change ).
#IBDesignable final class BarView: UIView {
lazy var pathAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 1
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
let red = UIColor(red: 249/255, green: 26/255, blue: 26/255, alpha: 1)
let orange = UIColor(red: 1, green: 167/255, blue: 463/255, alpha: 1)
let green = UIColor(red: 106/255, green: 239/255, blue: 47/255, alpha: 1)
lazy var backgroundColorAnimation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "fillColor")
animation.duration = 1
animation.fromValue = red.cgColor
animation.byValue = orange.cgColor
animation.toValue = green.cgColor
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
animation.fillMode = kCAFillModeBoth
animation.isRemovedOnCompletion = false
return animation
}()
#IBInspectable var spaceBetweenBars: CGFloat = 10
var numberOfBars: Int = 5
let data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
override func awakeFromNib() {
super.awakeFromNib()
initSublayers()
}
override func layoutSubviews() {
super.layoutSubviews()
setupLayers()
}
func setupLayers() {
let width = bounds.width - (spaceBetweenBars * CGFloat(numberOfBars + 1)) // There is n + 1 spaces between bars.
let barWidth: CGFloat = width / CGFloat(numberOfBars)
let scalePoint: CGFloat = bounds.height / 10.0 // 10.0 - 10 points is max
guard let sublayers = layer.sublayers as? [CAShapeLayer] else { return }
for i in 0...numberOfBars - 1 {
let barHeight: CGFloat = scalePoint * data[i]
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
var xPos: CGFloat!
if i == 0 {
xPos = spaceBetweenBars
} else if i == numberOfBars - 1 {
xPos = bounds.width - (barWidth + spaceBetweenBars)
} else {
xPos = barWidth * CGFloat(i) + spaceBetweenBars * CGFloat(i) + spaceBetweenBars
}
let startPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: 0)).cgPath
let endPath = UIBezierPath(rect: CGRect(x: xPos, y: bounds.height, width: barWidth, height: -barHeight)).cgPath
sublayers[i].path = startPath
pathAnimation.toValue = endPath
sublayers[i].removeAllAnimations()
sublayers[i].add(pathAnimation, forKey: "path")
sublayers[i].add(backgroundColorAnimation, forKey: "backgroundColor")
}
}
func initSublayers() {
for _ in 1...numberOfBars {
let shapeLayer = CAShapeLayer()
layer.addSublayer(shapeLayer)
}
}
}
The size ( height ) of bar depends of the data array, each sublayers has a different height. Based on this data I've crated a scale.
PathAnimation is changing height of the bars.
BackgroundColorAnimation is changing the collors of the path. It starts from red one, goes through the orange and finish at green.
My goal is to connect backgroundColorAnimation with data array as well as it's connected with pathAnimation.
Ex. When in data array is going to be value 1.0 then the bar going to be animate only to the red color which is a derivated from a base red color which is declared as a global variable. If the value in the data array going to be ex. 4.5 then the color animation will stop close to the delcared orange color, the 5.0 limit going to be this orange color or color close to this. Value closer to 10 going to be green.
How could I connect these conditions with animation properties fromValue, byValue, toValue. Is it an algorithm for that ? Any ideas ?
You have several problems.
You're setting fillMode and isRemovedOnCompletion. This tells me, to be blunt, that you don't understand Core Animation. You need to watch WWDC 2011 Session 421: Core Animation Essentials.
You're adding more layers every time layoutSubviews is called, but not doing anything with them.
You're adding animation every time layoutSubviews runs. Do you really want to re-animate the bars when the double-height “in-call” status bar appears or disappears, or on an interface rotation? It's probably better to have a separate animateBars() method, and call it from your view controller's viewDidAppear method.
You seem to think byValue means “go through this value on the way from fromValue to toValue”, but that's not what it means. byValue is ignored in your case, because you're setting fromValue and toValue. The effects of byValue are explained in Setting Interpolation Values.
If you want to interpolate between colors, it's best to use a hue-based color space, but I believe Core Animation uses an RGB color space. So you should use a keyframe animation to specify intermediate colors that you calculate by interpolating in a hue-based color space.
Here's a rewrite of BarView that fixes all these problems:
#IBDesignable final class BarView: UIView {
#IBInspectable var spaceBetweenBars: CGFloat = 10
var data: [CGFloat] = [5.5, 9.0, 9.5, 3.0, 8.0]
var maxDatum = CGFloat(10)
func animateBars() {
guard window != nil else { return }
let bounds = self.bounds
var flatteningTransform = CGAffineTransform.identity.translatedBy(x: 0, y: bounds.size.height).scaledBy(x: 1, y: 0.001)
let duration: CFTimeInterval = 1
let frames = Int((duration * 60.0).rounded(.awayFromZero))
for (datum, barLayer) in zip(data, barLayers) {
let t = datum / maxDatum
if let path = barLayer.path {
let path0 = path.copy(using: &flatteningTransform)
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.duration = 1
pathAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
pathAnimation.fromValue = path0
barLayer.add(pathAnimation, forKey: pathAnimation.keyPath)
let colors = gradient.colors(from: 0, to: t, count: frames).map({ $0.cgColor })
let colorAnimation = CAKeyframeAnimation(keyPath: "fillColor")
colorAnimation.timingFunction = pathAnimation.timingFunction
colorAnimation.duration = duration
colorAnimation.values = colors
barLayer.add(colorAnimation, forKey: colorAnimation.keyPath)
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
createOrDestroyBarLayers()
let bounds = self.bounds
let barSpacing = (bounds.size.width - spaceBetweenBars) / CGFloat(data.count)
let barWidth = barSpacing - spaceBetweenBars
for ((offset: i, element: datum), barLayer) in zip(data.enumerated(), barLayers) {
let t = datum / maxDatum
let barHeight = t * bounds.size.height
barLayer.frame = bounds
let rect = CGRect(x: spaceBetweenBars + CGFloat(i) * barSpacing, y: bounds.size.height, width: barWidth, height: -barHeight)
barLayer.path = CGPath(rect: rect, transform: nil)
barLayer.fillColor = gradient.color(at: t).cgColor
}
}
private let gradient = Gradient(startColor: .red, endColor: .green)
private var barLayers = [CAShapeLayer]()
private func createOrDestroyBarLayers() {
while barLayers.count < data.count {
barLayers.append(CAShapeLayer())
layer.addSublayer(barLayers.last!)
}
while barLayers.count > data.count {
barLayers.removeLast().removeFromSuperlayer()
}
}
}
private extension UIColor {
var hsba: [CGFloat] {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
return [hue, saturation, brightness, alpha]
}
}
private struct Gradient {
init(startColor: UIColor, endColor: UIColor) {
self.startColor = startColor
self.startHsba = startColor.hsba
self.endColor = endColor
self.endHsba = endColor.hsba
}
let startColor: UIColor
let endColor: UIColor
let startHsba: [CGFloat]
let endHsba: [CGFloat]
func color(at t: CGFloat) -> UIColor {
let out = zip(startHsba, endHsba).map { $0 * (1.0 - t) + $1 * t }
return UIColor(hue: out[0], saturation: out[1], brightness: out[2], alpha: out[3])
}
func colors(from t0: CGFloat, to t1: CGFloat, count: Int) -> [UIColor] {
var colors = [UIColor]()
colors.reserveCapacity(count)
for i in 0 ..< count {
let s = CGFloat(i) / CGFloat(count - 1)
let t = t0 * (1 - s) + t1 * s
colors.append(color(at: t))
}
return colors
}
}
Result:

Why doesn't UIView.animateWithDuration affect this custom view?

I designed a custom header view that masks an image and draws a border on the bottom edge, which is an arc. It looks like this:
Here's the code for the class:
class HeaderView: UIView
{
private let imageView = UIImageView()
private let dimmerView = UIView()
private let arcShape = CAShapeLayer()
private let maskShape = CAShapeLayer() // Masks the image and the dimmer
private let titleLabel = UILabel()
#IBInspectable var image: UIImage? { didSet { self.imageView.image = self.image } }
#IBInspectable var title: String? { didSet {self.titleLabel.text = self.title} }
#IBInspectable var arcHeight: CGFloat? { didSet {self.setupLayers()} }
// MARK: Initialization
override init(frame: CGRect)
{
super.init(frame:frame)
initMyStuff()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder:aDecoder)
initMyStuff()
}
override func prepareForInterfaceBuilder()
{
backgroundColor = UIColor.clear()
}
internal func initMyStuff()
{
backgroundColor = UIColor.clear()
titleLabel.font = Font.AvenirNext_Bold(24)
titleLabel.text = "TITLE"
titleLabel.textColor = UIColor.white()
titleLabel.layer.shadowColor = UIColor.black().cgColor
titleLabel.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
titleLabel.layer.shadowRadius = 0.0;
titleLabel.layer.shadowOpacity = 1.0;
titleLabel.layer.masksToBounds = false
titleLabel.layer.shouldRasterize = true
imageView.contentMode = UIViewContentMode.scaleAspectFill
addSubview(imageView)
dimmerView.frame = self.bounds
dimmerView.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.6)
addSubview(dimmerView)
addSubview(titleLabel)
// Add the shapes
self.layer.addSublayer(arcShape)
self.layer.addSublayer(maskShape)
self.layer.masksToBounds = true // This seems to be unneeded...test more
// Set constraints
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView .autoPinEdgesToSuperviewEdges()
titleLabel.autoCenterInSuperview()
}
func setupLayers()
{
let aHeight = arcHeight ?? 10
// Create the arc shape
arcShape.path = AppocalypseUI.createHorizontalArcPath(CGPoint(x: 0, y: bounds.size.height), width: bounds.size.width, arcHeight: aHeight)
arcShape.strokeColor = UIColor.white().cgColor
arcShape.lineWidth = 1.0
arcShape.fillColor = UIColor.clear().cgColor
// Create the mask shape
let maskPath = AppocalypseUI.createHorizontalArcPath(CGPoint(x: 0, y: bounds.size.height), width: bounds.size.width, arcHeight: aHeight, closed: true)
maskPath.moveTo(nil, x: bounds.size.width, y: bounds.size.height)
maskPath.addLineTo(nil, x: bounds.size.width, y: 0)
maskPath.addLineTo(nil, x: 0, y: 0)
maskPath.addLineTo(nil, x: 0, y: bounds.size.height)
//let current = CGPathGetCurrentPoint(maskPath);
//print(current)
let mask_Dimmer = CAShapeLayer()
mask_Dimmer.path = maskPath.copy()
maskShape.fillColor = UIColor(red: 0, green: 0, blue: 0, alpha: 1.0).cgColor
maskShape.path = maskPath
// Apply the masks
imageView.layer.mask = maskShape
dimmerView.layer.mask = mask_Dimmer
}
override func layoutSubviews()
{
super.layoutSubviews()
// Let's go old school here...
imageView.frame = self.bounds
dimmerView.frame = self.bounds
setupLayers()
}
}
Something like this will cause it to just snap to the new size without gradually changing its frame:
UIView.animate(withDuration: 1.0)
{
self.headerView.arcHeight = self.new_headerView_arcHeight
self.headerView.frame = self.new_headerView_frame
}
I figure it must have something to do with the fact that I'm using CALayers, but I don't really know enough about what's going on behind the scenes.
EDIT:
Here's the function I use to create the arc path:
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 startAngle = radians(180) + angle;
// let endAngle = radians(360) - angle;
let path = CGMutablePath();
path.addArc(nil, x: arcCenter.x, y: arcCenter.y, radius: arcRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false);
if(closed == true)
{path.addLineTo(nil, x: startPoint.x, y: startPoint.y);}
return path;
}
BONUS:
Setting the arcHeight property to 0 results in no white line being drawn. Why?
The Path property can't be animated. You have to approach the problem differently. You can draw an arc 'instantly', any arc, so that tells us that we need to handle the animation manually. If you expect the entire draw process to take say 3 seconds, then you might want to split the process to 1000 parts, and call the arc drawing function 1000 times every 0.3 miliseconds to draw the arc again from the beginning to the current point.
self.headerView.arcHeight is not a animatable property. It is only UIView own properties are animatable
you can do something like this
let displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode
let expectedFramesPerSecond = 60
var diff : CGFloat = 0
func update() {
let diffUpdated = self.headerView.arcHeight - self.new_headerView_arcHeight
let done = (fabs(diffUpdated) < 0.1)
if(!done){
self.headerView.arcHeight -= diffUpdated/(expectedFramesPerSecond*0.5)
self.setNeedsDisplay()
}
}

How to make a custom semi-circular progress indicator with terminator?

I'd like to make a custom semi-circular inverted progress indicator that looks like this:
I've tried with CAShapeLayer for background, progress line and progress line terminator (small white circle). I'm using ring.strokeEnd = progress to define where the progress line ends.
How to add the line terminator to the end of progress line?
Here's my code (note: I've hardcoded the position for the small white circle, as I don't know how to properly implement this):
class AKBezierson: UIView {
var ringBackground: CAShapeLayer!
var ring: CAShapeLayer!
var ringTerminator: CAShapeLayer!
var innerRect: CGRect!
var radius: CGFloat { return min(innerRect.width, innerRect.height) / 2 }
var progress: CGFloat = 0.8 { didSet { updateLayerProperties() } }
private struct Const {
static let gap: CGFloat = 10
static let width: CGFloat = 3.0
}
override func layoutSubviews() {
super.layoutSubviews()
innerRect = CGRectInset(bounds, Const.width / 2 + Const.gap, Const.width / 2 + Const.gap)
let path = UIBezierPath()
path.addArcWithCenter(
CGPointMake(bounds.width / 2, bounds.height / 2),
radius: radius,
startAngle: CGFloat(0),
endAngle: CGFloat(M_PI),
clockwise: true)
if (ringBackground == nil) {
ringBackground = CAShapeLayer()
layer.addSublayer(ringBackground)
ringBackground.path = path.CGPath
ringBackground.fillColor = nil
ringBackground.lineWidth = Const.width
ringBackground.strokeColor = UIColor(white: 1.0, alpha: 0.5).CGColor
}
ringBackground.frame = ringBackground.bounds
if (ring == nil) {
ring = CAShapeLayer()
layer.addSublayer(ring)
ring.path = path.CGPath
ring.fillColor = nil
ring.lineWidth = Const.width
ring.strokeColor = UIColor.whiteColor().CGColor
ring.strokeEnd = progress
}
ring.frame = ring.bounds
if (ringTerminator == nil) { // small circle
ringTerminator = CAShapeLayer()
layer.addSublayer(ringTerminator)
let terminatorCircle = UIBezierPath(ovalInRect: CGRectMake(15, 83, 12, 12))
ringTerminator.path = terminatorCircle.CGPath
ringTerminator.fillColor = UIColor.whiteColor().CGColor
ringTerminator.lineWidth = Const.width
ringTerminator.strokeColor = UIColor.whiteColor().CGColor
}
ringTerminator.frame = ringTerminator.bounds
updateLayerProperties()
}
func updateLayerProperties () {
if !(ring == nil) {
ring.strokeEnd = progress
}
}
}
I've also tried to calculate the point where the progress line ends and move the line terminator to a new position every time the progress changed, but I was not satisfied with the animation since the terminator was trying to reach the new position following the shortest (strait) path.