I'm trying to animate an arc to the left and right form its center (270 deg in Shape terms). This requires animatable data, which I've added, but the arc still doesn't seem to animate.
The view using the Arc is below. I've commented my intentions with the properties.
struct AverageGauge: View {
#State var endAngle: Angle = Angle(degrees: 271.0)
var clockwise: Bool {
get { endAngle.degrees > 270 ? false : true }
}
var body: some View {
Arc(startAngle: .degrees(270), endAngle: endAngle,
clockwise: clockwise)
.stroke(.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 300, height: 300)
// Tap gesture as stand in for changing data
.onTapGesture {
withAnimation(Animation.easeIn(duration: 10.0)) {
endAngle = Angle(degrees: Double.random(in: 180...360))
}
}
}
}
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
// Animatable endAngle
var endAngleAnimatable: Angle {
get { endAngle }
set { endAngle = newValue }
}
// Animatable clockwise bool
var clockwiseAnimatable: Bool {
get { clockwise }
set { clockwise = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: startAngle, endAngle: endAngleAnimatable, clockwise: clockwiseAnimatable)
return path
}
}
When I set the clockwise in the Arc to a constant, it still doesn't animate, so I suppose the Bool isn't what's causing the problem.
Here's a gif of the arc being instantly redrawn rather than animated:
I got this working by using Double for endAngle rather than Angle. The Double is then converted to an Angle using .degrees(Double) in the arc. I guess creating Angles isn't animatable.
Per #Yrb's suggestion above, I conformed Angle to VectorArithmetic and the animation works without having to change endAngle do a Double:
extension Angle: VectorArithmetic {
public static var zero = Angle(degrees: 0.0)
public static func + (lhs: Angle, rhs: Angle) -> Angle {
Angle(degrees: lhs.degrees + rhs.degrees)
}
public static func - (lhs: Angle, rhs: Angle) -> Angle {
Angle(degrees: lhs.degrees - rhs.degrees)
}
public static func += (lhs: inout Angle, rhs: Angle) {
lhs = Angle(degrees: lhs.degrees + rhs.degrees)
}
public static func -= (lhs: inout Angle, rhs: Angle) {
lhs = Angle(degrees: lhs.degrees - rhs.degrees)
}
public mutating func scale(by rhs: Double) {
self.degrees = self.degrees * rhs
}
public var magnitudeSquared: Double {
get { 0.0 }
}
}
magnitudeSquared isn't properly implemented. Just a filler stub.
Related
I am trying to make pie chart as below which is working fine but I have issue with end path (which is orange in below image).
What I want to do is make end of orange shape to below the green one so that I can achieve as below.
Any suggestion how this can be done?
Code can be found at below link.
https://drive.google.com/file/d/1ST0zNooLgRaI8s2pDK3NMjBQYjBSRoXB/view?usp=sharing
Below is what I have.
func drawBeizer(start_angle : CGFloat, end_angle : CGFloat, final_color : UIColor) {
let path1 : UIBezierPath = UIBezierPath()
path1.addArc(withCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2), radius: ((self.frame.size.width-main_view_width)/2), startAngle: start_angle, endAngle: end_angle, clockwise: true)
path1.lineWidth = main_view_width
path1.lineCapStyle = .round
final_color.setStroke()
path1.stroke()
}
This function I am passing start angle and end angle & color for the path.
This solution is for SwiftUI using Path.
struct DonutElement: Shape{
var width: CGFloat = 50
var startAngle: Angle
var endAngle: Angle
func path(in rect: CGRect) -> Path {
// define commonly used vars
// the extend of the shape
let outerRadius = min(rect.width, rect.height) / 2
// the middle of the shape
let midRadius = outerRadius - width / 2
// the inner radius
let innerRadius = outerRadius - width
// centerpoint used to move coordinate system in to center
let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
return Path{ path in
// calculate the startpoint from the startAngle. startAngle is allready normalized
let startPoint = startAngle.polarCoordinates(center: center, radius: outerRadius)
// move the path without drawing to the starting position
path.move(to: startPoint)
// add the starting concav arc
// the center point for this arc are the polar coordinates relative to midradius of the donut
path.addArc(center: startAngle.polarCoordinates(center: center, radius: midRadius), radius: width / 2, startAngle: startAngle.normalizing(), endAngle: (.init(degrees: 180) - startAngle), clockwise: true)
// add the arc that presents the inner donut line
// center point is the center of the drawing rect with normalized angles
path.addArc(center: center, radius: innerRadius, startAngle: startAngle.normalizing(), endAngle: endAngle.normalizing(), clockwise: true)
// add the convex arc at the end of the donut element
// switch clockwise to false and swap end and start angle
// replace startAngle with endAngle
path.addArc(center: endAngle.polarCoordinates(center: center, radius: midRadius), radius: width / 2, startAngle: (.init(degrees: 180) - endAngle), endAngle: endAngle.normalizing(), clockwise: false)
// add the outer stroke to close the shape
path.addArc(center: center, radius: outerRadius, startAngle: endAngle.normalizing(), endAngle: startAngle.normalizing(), clockwise: false)
// just in case
path.closeSubpath()
}
}
}
extension Shape {
func fillWithStroke<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
self
.stroke(strokeStyle, lineWidth: lineWidth)
.background(self.fill(fillStyle))
}
}
struct AngleContainer: Identifiable, Hashable{
let id = UUID()
var startAngle: Angle
var endAngle: Angle
var color: Color
}
struct Donut: View{
// each element ends here and starts at the previous or 0. Values in percent
var elementStops: [(CGFloat , Color)] = [(0.4 , .blue), (45 , .red), (55 , .green), (78 , .gray), (100 , .white)]
var width: CGFloat = 50.0
private var angles: [AngleContainer] {
var angles = [AngleContainer]()
for (index, stop) in elementStops.enumerated(){
if index == 0{
let startAngle = Angle(degrees: 0)
let endAngle = Angle(degrees: stop.0/100 * 360)
angles.append(AngleContainer(startAngle: startAngle,endAngle: endAngle,color: stop.1))
} else{
let startAngle = Angle(degrees: elementStops[index - 1].0 / 100 * 360)
let endAngle = Angle(degrees: stop.0/100 * 360)
angles.append(AngleContainer(startAngle: startAngle,endAngle: endAngle,color: stop.1))
}
}
return angles
}
var body: some View{
ZStack{
ForEach(angles){ angleContainer in
DonutElement(width: width, startAngle: angleContainer.startAngle, endAngle: angleContainer.endAngle)
.fillWithStroke(angleContainer.color, strokeBorder: .black.opacity(0.2))
}
}
.background(.white)
}
}
// used for transfering coordinate system
extension CGPoint{
mutating func transfered(to center: CGPoint){
x = center.x + x
y = center.y - y
}
func transfering(to center: CGPoint) -> CGPoint{
.init(x: center.x + x, y: center.y - y)
}
}
I am normalizing angles to behave more like in math with 90 degrees at the top and not at the bottom.
// convenience so angle feels more natural
extension Angle{
func normalizing() -> Angle {
Angle(degrees: 0) - self
}
func polarCoordinates(center: CGPoint, radius: CGFloat) -> CGPoint{
CGPoint(x: cos(self.radians) * radius, y: sin(self.radians) * radius )
.transfering(to: center)
}
}
Result:
I found a way to do this. Below is the full code.
I am adding other addArc using end angles.
ViewController.swift
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var myView: MyView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
myView.setupData(inner_color: [.green, .purple, .blue, .orange], inner_view_width: self.view.frame.width/5.0, inner_angle: [0.0, 90.0, 180.0, 270.0])
myView.backgroundColor = UIColor.white
}
}
MyView.swift
import UIKit
class MyView: UIView {
var main_radius : CGFloat = 0.0
var main_color : [UIColor] = [UIColor.black, UIColor.green]
var main_view_width : CGFloat = 1.0
var main_angle : [CGFloat] = [-CGFloat.pi, 0.0]
var actual_angle : [CGFloat] = []
func setupData(inner_color : [UIColor], inner_view_width : CGFloat, inner_angle : [CGFloat]) {
main_color = inner_color
main_view_width = inner_view_width
main_angle = inner_angle
actual_angle = inner_angle
var temp_main_angle : [CGFloat] = []
for i in 0..<main_angle.count {
temp_main_angle.append(main_angle[i]*Double.pi/180)
}
main_angle = temp_main_angle
draw(CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.width))
}
override func draw(_ rect: CGRect) {
if (main_color.count >= 2) {
var inner_position : Int = 0
// first draw with sharp corner
for i in 0..<main_color.count {
if (i == (main_color.count-1)) {
inner_position = 0
} else {
inner_position = i+1
}
drawBeizer(start_angle: main_angle[i], end_angle: main_angle[inner_position], final_color: main_color[i])
}
// append with ending icons for circle
for i in 0..<main_color.count {
if (i == (main_color.count-1)) {
inner_position = 0
} else {
inner_position = i+1
}
drawBeizer(start_angle: main_angle[inner_position], end_angle: main_angle[inner_position]+(1*Double.pi/180), final_color: main_color[i], withCircle: true) // make here final_color as .black to see what I am doing with this loop
}
}
}
func drawBeizer(start_angle : CGFloat, end_angle : CGFloat, final_color : UIColor, withCircle: Bool = false) {
let path1 : UIBezierPath = UIBezierPath()
path1.addArc(withCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2), radius: ((self.frame.size.width-main_view_width)/2), startAngle: start_angle, endAngle: end_angle, clockwise: true)
path1.lineWidth = main_view_width
if (withCircle) {
path1.lineCapStyle = .round
}
final_color.setStroke()
path1.stroke()
}
}
Full code here
I have an animated Arc drawn in SwiftUI that represents data coming from two devices. When the arc animates to the left, the "gauge" is indicating that left device's data is higher than the right's and vice versa. The "top" is zero, which is 270 degrees when drawing an Arc shape. Because of this, I have a conditional set on a clockwise property so that the animation appears to go to the left or right of zero (270):
var clockwise: Bool {
get { endAngle.degrees > 270 ? false : true }
}
When the property endAngle goes from less than 270 to greater than 270, or the reverse of this, the arc isn't drawn properly and appears as a circle because clockwise isn't set for portion of the animation passing through 270 to the new endAngle.
Is there a way to delay the change in clockwise until the animation passes through 270 degrees?
I've included the code for the view below with some comments. In order to animate Angle, it has to conform to VectorArithmetic which is the reason for the extension.
struct AverageGauge: View {
#State var endAngle = Angle(degrees: 271.0)
// Property I'd like to update during the animation
var clockwise: Bool {
get { endAngle.degrees > 270 ? false : true }
}
var body: some View {
VStack {
Arc(startAngle: .degrees(270), endAngle: endAngle,
clockwise: clockwise)
.stroke(.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 100, height: 100)
// Tap gesture simulates changing data
.onTapGesture {
withAnimation(Animation.easeIn(duration: 2.0)) {
// endAngle animated here
endAngle = Angle(degrees: Double.random(in: 180...360))
}
}
Text(String(describing: endAngle.degrees))
Text("\(String(clockwise))")
}
}
}
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
// var startAngleAnimatable: Angle {
// get { startAngle }
// set {startAngle = Angle(degrees: 270.0) }
// }
// Required to animate endAngle
var animatableData: Angle {
get { endAngle }
set { endAngle = newValue }
}
// var clockwiseAnimatable: Bool {
// get { clockwise }
// set { clockwise = newValue }
// }
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: startAngle, endAngle: endAngle, clockwise: clockwise)
return path
}
}
extension Angle: VectorArithmetic {
public static var zero = Angle(degrees: 0.0)
public static func + (lhs: Angle, rhs: Angle) -> Angle {
Angle(degrees: lhs.degrees + rhs.degrees)
}
public static func - (lhs: Angle, rhs: Angle) -> Angle {
Angle(degrees: lhs.degrees - rhs.degrees)
}
public static func += (lhs: inout Angle, rhs: Angle) {
lhs = Angle(degrees: lhs.degrees + rhs.degrees)
}
public static func -= (lhs: inout Angle, rhs: Angle) {
lhs = Angle(degrees: lhs.degrees - rhs.degrees)
}
public mutating func scale(by rhs: Double) {
self.degrees = self.degrees * rhs
}
public var magnitudeSquared: Double {
get { 0.0 }
}
}
// Preview in case you want to paste it.
struct AverageGauge_Previews: PreviewProvider {
static var previews: some View {
AverageGauge()
}
}
Here's a gif of the animation. You can see that when the new value is on the same side as 270, it looks normal, however, when the animation traverses zero (270) it appears as a circle because clockwise is set incorrectly.
I simplified it with a trimmed circle, leading to the same result.
If change in value from + to - or vice versa, I wait for the first animation to finish before starting the second.
struct ContentView: View {
#State var gaugeValue: Double = 0.8 // now in values -1 (-45°) to +1 (+45°)
var body: some View {
VStack {
let fraction: CGFloat = abs(gaugeValue) * 0.25
let rotation = gaugeValue < 0 ? (gaugeValue * 90) - 90 : -90
Circle()
.trim(from: 0, to: fraction )
.rotation(Angle(degrees: rotation), anchor: .center)
.stroke(.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 100, height: 100)
// Tap gesture simulates changing data
Button {
let newGaugeValue = CGFloat.random(in: -1 ... 1)
if newGaugeValue * gaugeValue < 0 { // if change of +/-
withAnimation(Animation.easeOut(duration: 1.0)) {
gaugeValue = 0
}
// delay for reaching 0 point
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation(Animation.easeIn(duration: 1.0)) {
gaugeValue = newGaugeValue
}
}
} else { // no change of +/-
withAnimation(Animation.easeIn(duration: 1.0)) {
gaugeValue = newGaugeValue
}
}
} label: {
Text("New Data")
}
Text("\(gaugeValue)")
}
}
}
I have the following arc:
struct Arc : Shape
{
#Binding var endAngle: Double
var center: CGPoint
var radius: CGFloat
func path(in rect: CGRect) -> Path
{
var path = Path()
path.addArc(center: center, radius: radius, startAngle: .degrees(270), endAngle: .degrees(endAngle), clockwise: false)
return path.strokedPath(.init(lineWidth: 50, lineCap: .round))
}
}
How can I add shadow, similar to the activity arcs on Apple Watch, such that at full circle the endAngle is still discernible?
EDIT:
There's the additional issue at 360+ degrees (so full circle), that both arc ends get combined and are shown as a radial line (I see this because I've a applied an AngularGradient). Of course Arc won't do advanced things like continuing above the startAngle position, like the Apple Watch arcs do. But this was what I was looking for. Does anyone know how to do that in SwiftUI?
what about this? ok...bit "tricky"
import SwiftUI
struct Arc: Shape {
#Binding var startAngle: Double
#Binding var endAngle: Double
var center: CGPoint
var radius: CGFloat
var color: Color
func path(in rect: CGRect) -> Path {
var path = Path()
let cgPath = CGMutablePath()
cgPath.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: CGFloat(startAngle), endAngle: CGFloat(endAngle), clockwise: true)
// path.addArc(center: center, radius: radius, startAngle: .degrees(270), endAngle: .degrees(endAngle), clockwise: false)
path = Path(cgPath)
return path.strokedPath(.init(lineWidth: 50, lineCap: .round))
}
}
struct ContentView: View {
var body: some View {
ZStack() {
// Arc(endAngle: .constant(269), center: CGPoint(x: 200, y: 200), radius: 150, color: .red).foregroundColor(.red).opacity(0.2)
Arc(startAngle: .constant(80 + 271), endAngle: .constant(80 + 271 + 340), center: CGPoint(x: 205, y: 205), radius: 150, color: .red).foregroundColor(.red)//.shadow(color: .black, radius: 5, x: -30, y: -170)
Arc(startAngle: .constant(90), endAngle: .constant(80 + 270), center: CGPoint(x: 200, y: 200), radius: 150, color: .red).foregroundColor(.red).shadow(color: .black, radius: 5, x: -114, y: -230)
}
.background(Color.black)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
do you mean something like this?
struct Arc : Shape
{
#Binding var endAngle: Double
var center: CGPoint
var radius: CGFloat
var color: Color
func path(in rect: CGRect) -> Path
{
var path = Path()
path.addArc(center: center, radius: radius, startAngle: .degrees(270), endAngle: .degrees(endAngle), clockwise: false)
return path.strokedPath(.init(lineWidth: 50, lineCap: .round))
}
}
struct ContentView: View {
var body: some View {
ZStack() {
Arc(endAngle: .constant(269), center: CGPoint(x: 200, y: 200), radius: 150, color: .red).foregroundColor(.red).opacity(0.2)
Arc(endAngle: .constant(90), center: CGPoint(x: 200, y: 200), radius: 150, color: .red).foregroundColor(.red)
}
}
}
Hi you can fix and handle all of these features by writing an extension in your project file
import UIKit
extension UIView {
func roundCorners(corners: UIRectCorner, radius: CGFloat) {
let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
// MARK: Shadow extension UIView {
func dropShadow(scale: Bool = true) {
layer.masksToBounds = false
layer.shadowColor = UIColor.blue.cgColor
layer.shadowOpacity = 1.0
layer.shadowOffset = CGSize(width: -1.5, height: 3)
layer.shadowRadius = 3
layer.shadowPath = UIBezierPath(rect: bounds).cgPath
layer.shouldRasterize = true
layer.rasterizationScale = scale ? UIScreen.main.scale : 1
}
func dropShadow(color: UIColor, opacity: Float = 0.5, offSet: CGSize, radius: CGFloat = 1, scale: Bool = true) {
layer.masksToBounds = false
layer.shadowColor = color.cgColor
layer.shadowOpacity = opacity
layer.shadowOffset = offSet
layer.shadowRadius = radius
layer.shadowPath = UIBezierPath(rect: self.bounds).cgPath
layer.shouldRasterize = true
layer.rasterizationScale = scale ? UIScreen.main.scale : 1
}
func setAnchorPoint(_ point: CGPoint) {
var newPoint = CGPoint(x: bounds.size.width * point.x, y: bounds.size.height * point.y)
var oldPoint = CGPoint(x: bounds.size.width * layer.anchorPoint.x, y: bounds.size.height * layer.anchorPoint.y)
newPoint = newPoint.applying(transform)
oldPoint = oldPoint.applying(transform)
var position = layer.position
position.x -= oldPoint.x
position.x += newPoint.x
position.y -= oldPoint.y
position.y += newPoint.y
layer.position = position
layer.anchorPoint = point
}
class func fromNib<T: UIView>() -> T {
guard let view = Bundle.main.loadNibNamed(String(describing: T.self), owner: nil, options: nil)?.first as? T else { fatalError() }
return view
}
now in storyboard you can handle shadows
or you don't want to, and it's too much, you can use the only part you need.
I would like to place UICollectionViewCells along the circular arc (UIBezierPath). Please suggest a formula to calculate center of these cells.
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
}
All these cells are of same constant size, say (100, 100) and x distance (horizontal) between two cells is also constant, say 20.
let startAngle: Double = 0
let endAngle: Double = 180
var arcRadius: CGFloat = 414 // Actually updated dynamically when layout changes
var arcCenter: CGPoint {
get {
return CGPoint(x: contentView.bounds.width/2, y: contentView.bounds.height/2 - arcRadius)
}
}
func drawArc() {
contentView.layer.sublayers?.forEach {
if $0 is CAShapeLayer {
$0.removeFromSuperlayer()
}
}
// Create an arc
let path = UIBezierPath(arcCenter: arcCenter,
radius: arcRadius,
startAngle: degreesToRadians(value: startAngle),
endAngle: degreesToRadians(value: endAngle),
clockwise: true)
// Create a shape layer and add path
let shapeLayer = createShapeLayer()
shapeLayer.path = path.cgPath
contentView.layer.addSublayer(shapeLayer)
}
It can be achieved using parametric equation of circle. Here is my code -
Create a Circle object and then call its func point(startAngle: forItemAtIndex:) to find the center point for these cells.
struct Circle {
var center: CGPoint
var radius: CGFloat
func point(startAngle: Double, forItemAtIndex index: Int) -> CGPoint {
/*
This is based on parametric equation of a circle with center (cx, cy) -
x = cx + radius*cos(angle)
y = cy + radius*sin(angle)
*/
var deltaX: CGFloat = 122 // Fixed horizontal (x) distance between 2 cells' centers
var deltaY = radius - sqrt(pow(radius, 2) - pow(deltaX, 2)) // formula to calculate vertical distance between cells' centers
var actualY = center.y + radius - deltaY
var deltaAngleRadians = asin((actualY - center.y)/radius)
var deltaAngle = radiansToDegrees(value: deltaAngleRadians)
var stepAngle: Double = deltaAngle - startAngle
var actualAngle = startAngle + (Double(index)*stepAngle)
let viewCenterX = radius * cos(degreesToRadians(value: actualAngle)) + center.x
let viewCenterY = radius * sin(degreesToRadians(value: actualAngle)) + center.y
var position = CGPoint(x: viewCenterX, y: viewCenterY)
return position
}
func bezierPath(startAngle: Double = 0, endAngle: Double = 180, clockwise: Bool = true) -> UIBezierPath {
return UIBezierPath(arcCenter: center, radius: radius, startAngle: degreesToRadians(value: startAngle), endAngle: degreesToRadians(value: endAngle), clockwise: true)
}
func degreesToRadians(value: Double) -> CGFloat {
return CGFloat(value * .pi / 180.0)
}
func radiansToDegrees(value: CGFloat) -> Double {
return Double(value * 180.0 / .pi)
}
}
I have an UIView with bounding to a circle, something like this
I want to make an effect like stretching it, see the following image
It likes a pullToRefresh effect, there are many libraries or opensources to make pullToRefresh, but they're always belongs to tableview. I just want to make the stretching effect independently. I just have a circled UIView and it's stretched when I pull it
How can I make it
The basic idea is to use bezier paths to outline the precise shape you're looking for. You can then use gesture to change that shape and use display link to animate the returning of the stretched circle back to its circular form:
#IBDesignable
class RefreshView: UIView {
private let shapeLayer = CAShapeLayer()
#IBInspectable
var fillColor: UIColor = UIColor.lightGrayColor() {
didSet {
shapeLayer.fillColor = fillColor.CGColor
}
}
#IBInspectable
var strokeColor: UIColor = UIColor.clearColor() {
didSet {
shapeLayer.strokeColor = strokeColor.CGColor
}
}
#IBInspectable
var lineWidth: CGFloat = 0.0 {
didSet {
shapeLayer.strokeColor = strokeColor.CGColor
}
}
/// Center of main circle is in center top
private var pullDownCenter: CGPoint {
return CGPoint(x: bounds.size.width / 2.0, y: bounds.size.width / 2.0)
}
/// Radius of view spans width of view
private var radius: CGFloat {
return bounds.size.width / 2.0
}
override var frame: CGRect {
get {
return super.frame
}
set {
super.frame = newValue
updatePath()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
configureView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configureView()
}
/// Update the path, add shape layer, and add gesture recognizer
private func configureView() {
shapeLayer.fillColor = fillColor.CGColor
shapeLayer.strokeColor = strokeColor.CGColor
shapeLayer.lineWidth = lineWidth
updatePath()
layer.addSublayer(shapeLayer)
let pan = UIPanGestureRecognizer(target: self, action: #selector(RefreshView.handlePan(_:)))
addGestureRecognizer(pan)
}
/// Update path
private func updatePath() {
shapeLayer.path = stretchyCirclePathWithCenter(pullDownCenter, radius: radius, yOffset: yOffset).CGPath
}
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
yOffset = yOffsetMax
}
// MARK: Gesture Recognizer
private var yOffset: CGFloat = 0.0 { didSet { updatePath() } }
private var yOffsetMax: CGFloat { return bounds.size.width * 1.5 }
private var yOldOffset: CGFloat = 0.0
func handlePan(gesture: UIPanGestureRecognizer) {
if gesture.state == .Began {
yOldOffset = yOffset
} else if gesture.state == .Changed {
yOffset = yOldOffset + max(0, min(gesture.translationInView(gesture.view).y, yOffsetMax))
} else if gesture.state == .Ended || gesture.state == .Cancelled {
animateBackToCircle()
}
}
// MARK: Animation
private var displayLink: CADisplayLink?
private var duration: CGFloat?
private var startTime: CFAbsoluteTime?
private var originalOffset: CGFloat?
private func animateBackToCircle() {
displayLink = CADisplayLink(target: self, selector: #selector(RefreshView.handleDisplayLink(_:)))
duration = 0.5
originalOffset = yOffset
startTime = CFAbsoluteTimeGetCurrent()
displayLink?.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)
}
func handleDisplayLink(displayLink: CADisplayLink) {
let percent = CGFloat(CFAbsoluteTimeGetCurrent() - startTime!) / duration!
if percent < 1.0 {
yOffset = originalOffset! * (1.0 - sin(percent * CGFloat(M_PI_2)))
} else {
self.displayLink?.invalidate()
self.displayLink = nil
updatePath()
}
}
// MARK: Stretch circle path
private func stretchyCirclePathWithCenter(center: CGPoint, radius: CGFloat, yOffset: CGFloat = 0.0) -> UIBezierPath {
func pointWithCenter(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
return CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle))
}
if yOffset == 0 {
return UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: 2.0 * CGFloat(M_PI), clockwise: true)
}
let lowerRadius = radius * (1 - yOffset / yOffsetMax * 0.5)
let yOffsetTop = yOffset / 4
let yOffsetBottom = yOffset / 1.5
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat(M_PI), endAngle: 0, clockwise: true)
path.addCurveToPoint(CGPoint(x: center.x + lowerRadius, y:center.y + yOffset), controlPoint1: CGPoint(x: center.x + radius, y:center.y + yOffsetTop), controlPoint2: CGPoint(x: center.x + lowerRadius, y:center.y + yOffset - yOffsetBottom))
path.addArcWithCenter(CGPoint(x: center.x, y:center.y + yOffset), radius: lowerRadius, startAngle: 0, endAngle: CGFloat(M_PI), clockwise: true)
path.addCurveToPoint(CGPoint(x: center.x - radius, y:center.y), controlPoint1: CGPoint(x: center.x - lowerRadius, y:center.y + yOffset - yOffsetBottom), controlPoint2: CGPoint(x: center.x - radius, y:center.y + yOffsetTop))
return path
}
}
This renders something like:
Clearly, feel free to play around with the path as you see fit, add additional flourishes like a spinning arrow or whatever, etc. But this illustrates the basics of constructing bezier path, stretching it with gesture, and animating it back to its circular shape.
There is already a control available for you:
circle with strech
Apart from above i will give a try to this approach also:
I think you can have full streched image, hidden by white view above it. As user swips down or up you can show/hide part of streched image.
if user have not swiped then show unstreched circle image, and if user have swipe a thresold distance show fully streched image.
You will likely need to do custom drawing. Just stretching a view will cause there to be aliasing and the view to be misshaped.
To do this you should subclass UIView and override drawRect to do some kind of drawing that can respond to user interaction.
I've created a sample class:
class CircleRefresh:UIView{
var taperFactor:CGFloat = 0.00{
didSet{
self.setNeedsDisplay()
}
}
var heightFactor: CGFloat = 0.40
{
didSet{
self.setNeedsDisplay()
}
}
var radius:CGFloat
{
return self.frame.size.height * 0.20
}
//Set this to add an arrow or another image to the top circle
var refreshImage:UIImage?{
didSet{
//
self.subviews.forEach{$0.removeFromSuperview()}
if let image = self.refreshImage{
let imageView = UIImageView(frame: CGRect(x:self.frame.size.width * 0.5 - self.radius,y:0.0,width:self.radius * 2.0,height:self.radius * 2.0))
imageView.image = image
imageView.contentMode = .ScaleAspectFit
imageView.layer.cornerRadius = imageView.frame.size.height * 0.5
imageView.layer.masksToBounds = true
self.addSubview(imageView)
}
}
}
override func drawRect(rect:CGRect)
{
UIColor.lightGrayColor().setFill()
let endCenter = CGPoint(x:self.frame.size.width * 0.5,y:self.frame.size.height * heightFactor - radius * taperFactor)
//top arc
let path = UIBezierPath(arcCenter: CGPoint(x:self.frame.size.width * 0.5, y:radius), radius: self.frame.size.height * 0.20, startAngle: 0.0, endAngle: CGFloat(M_PI), clockwise: false)
//left curve
path.addCurveToPoint(CGPoint(x:endCenter.x - radius * taperFactor,y:endCenter.y), controlPoint1: CGPoint(x:endCenter.x - radius, y:radius * 2.0), controlPoint2: CGPoint(x: endCenter.x - radius * taperFactor, y: radius * 2.0))
//bottom arc
path.addArcWithCenter(endCenter, radius: radius * taperFactor, startAngle: CGFloat(M_PI), endAngle: 0.0, clockwise: false)
//right curve
path.addCurveToPoint(CGPoint(x:endCenter.x + radius,y:radius), controlPoint1: CGPoint(x: endCenter.x + radius * taperFactor, y: radius * 2.0),controlPoint2: CGPoint(x:endCenter.x + radius, y:radius * 2.0))
path.fill()
}
}
This will draw a circle but changing taperFactor and heightFactor will give a stretching effect. Setting heightFactor to 1.0 will mean the drawing will take up the entire height of the view and setting taperFactor to 1.0 will cause the tail of the drawing to be as wide as the top circle.
To get a sense for how changing heightFactor and taperFactor changes the drawing, you can then add a pan gesture recognizer to this view and in it's action say something like:
#IBAction func pan(sender: UIPanGestureRecognizer) {
let location = sender.locationInView(self.view)
circle.heightFactor = location.y / self.view.frame.size.height
circle.taperFactor = circle.heightFactor * 0.5 - 0.20
}
You can change heightFactor and taperFactor to achieve different effects. This is only part of how to make a refresh controller but seems to be the thrust of your question.