0

I have an UIView with bounding to a circle, something like this

enter image description here

I want to make an effect like stretching it, see the following image

enter image description here

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

Scofield Tran
  • 828
  • 1
  • 18
  • 30

3 Answers3

4

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:

enter image description here

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.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • how to make this animation by contour lines? And on both sides in the right and left – Maxim Zakopaylov Feb 23 '17 at 12:30
  • I don't understand what you mean "by contour lines". I'd actually suggest you just post your own question... – Rob Feb 23 '17 at 15:33
  • Hey Rob. I've posted a new and interesting challenge that similar to the one you solved above. We are trying to solve it for a few days and we'll love for some help: https://stackoverflow.com/questions/51021748/circle-with-fluid-stechy-stroke – Roi Mulia Jun 25 '18 at 10:53
0

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.

maddy
  • 4,001
  • 8
  • 42
  • 65
0

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.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
beyowulf
  • 15,101
  • 2
  • 34
  • 40