skip to Main Content

Bit stumped here.

First of all, here is what’s going on:

enter image description here

As you can see, my ball is not following the curved quarter-circle path exactly, but vaguely.

Here is the code creating the quarter-circle (p.s. – my container view is 294 units tall and wide):

let startAngle = CGFloat(Double.pi * 2) // top of circle
let endAngle = startAngle + 2 * Double.pi * 0.25

view.layoutIfNeeded()

smallCircleView.parentVC = self
smallCircleView.layer.cornerRadius = 45/2

let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: containerView.frame.self.width, startAngle: startAngle, endAngle: endAngle, clockwise: true)

And here is the code shifting the ball around:

func shiftSmallCircleView(newX : CGFloat){
    smallCircleViewLeadingConstraint.constant = newX
    let angle = (newX/containerView.frame.self.width)*90 + 180
    let y = containerView.frame.size.width * cos((Double.pi * 2 * angle) / 360)
    smallCircleViewBottomConstraint.constant = y + containerView.frame.origin.y
}

Since I’m using the cos function, should the ball’s path be identical to the original quarter-circle path? How can they be similar but not identical?

Edit:
New outcome with updated code: enter image description here

let angle = (distanceDelta/containerView.frame.self.width) * -90.0
containerView.transform = CGAffineTransform.init(rotationAngle: angle * Double.pi/180)

Most recent edit:
enter image description here

let angle = (distanceDelta/pathContainerView.frame.self.width) * .pi / -180.0
containerView.transform = CGAffineTransform.init(rotationAngle: angle)

All code:

class SmallCircleView : UIView {

var parentVC : ViewController!

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first as? UITouch {
        let point = touch.location(in: self)
    }
    
    super.touchesBegan(touches, with: event)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first as? UITouch {
        let point = touch.location(in: self.superview)=
        parentVC.shiftSmallCircleView(distanceDelta: point.x)=
    }
}

}

class ViewController: UIViewController {

@IBOutlet var containerView : UIView!
@IBOutlet var pathContainerView : UIView!
@IBOutlet var smallCircleView : SmallCircleView!
@IBOutlet var smallCircleViewLeadingConstraint : NSLayoutConstraint!
@IBOutlet var smallCircleViewBottomConstraint : NSLayoutConstraint!

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    
    let startAngle = CGFloat(Double.pi * 2) // top of circle
    let endAngle = startAngle + 2 * Double.pi * 0.25
    
    view.layoutIfNeeded()
    
    smallCircleView.parentVC = self
    smallCircleView.layer.cornerRadius = 45/2
    
    let circlePath = UIBezierPath(arcCenter: CGPoint(x: 0, y: 0), radius: pathContainerView.frame.self.width, startAngle: startAngle, endAngle: endAngle, clockwise: true)
    
    let shapeLayer = CAShapeLayer()

    // The Bezier path that we made needs to be converted to
    // a CGPath before it can be used on a layer.
    shapeLayer.path = circlePath.cgPath

    // apply other properties related to the path
    shapeLayer.strokeColor = UIColor.blue.cgColor
    shapeLayer.fillColor = UIColor.white.cgColor
    shapeLayer.lineWidth = 1.0
    shapeLayer.position = CGPoint(x: 0, y: 0)

    // add the new layer to our custom view
    pathContainerView.layer.addSublayer(shapeLayer)
    
    containerView.bringSubviewToFront(smallCircleView)
}

func shiftSmallCircleView(distanceDelta : CGFloat){
    let degrees = min(1, (distanceDelta/pathContainerView.frame.size.width)) * -90
    containerView.transform = CGAffineTransform.init(rotationAngle: degrees * M_PI/180)
}


}

2

Answers


  1. I’m on my phone right now and so I can’t provide the code but there is a much much easier way to do this. Don’t bother trying to work out what coordinates the ball needs to be at. Just place the ball into a rectangular view with the centre of this view being at the centre of your circle and the ball being on the path. (Make the container view invisible).

    Now… rotate the container view.

    That’s it.

    Because the ball is a child of the view it will be moved as part of the rotation. And the movement will follow a circle centred around the point of rotation. Which is the centre of the container view.

    Example

    I made a quick example to show what I mean. In essence… cheat. Don’t actually do the hard maths to work out where the ball will be. Use methods to make it look the same in an easier way…

    Here is my storyboard… enter image description here

    And the code…

    class ViewController: UIViewController {
    
        @IBOutlet weak var containerView: UIView!
        @IBOutlet weak var circleView: UIView!
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .white
            circleView.layer.cornerRadius = 20
        }
    
        @IBAction func sliderChanged(_ sender: UISlider) {
            let rotationAngle = sender.value * .pi / 180
    
            containerView.transform = CGAffineTransform(rotationAngle: CGFloat(rotationAngle))
        }
    }
    

    And an animation…

    enter image description here

    And if you make the container background clear…

    enter image description here

    Login or Signup to reply.
  2. To answer your original question…

    I haven’t double-checked your math, but this is another method of positioning your "small circle" view.

    Using these two "helper" extensions:

    extension CGPoint {
        static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)
            return CGPoint(x: x, y: y)
        }
    }
    extension CGFloat {
        var degreesToRadians: Self { self * .pi / 180 }
        var radiansToDegrees: Self { self * 180 / .pi }
    }
    

    We can find the point on the arc for a given angle like this:

    let arcCenter: CGPoint = .zero
    let radius: CGFloat = 250
    let degree: CGFloat = 45
    let p = CGPoint.pointOnCircle(center: arcCenter, radius: radius, angle: degree.degreesToRadians)
    

    We can then move the "ball" to that point:

    circleView.center = p
    

    To get the circle view to "roll along the inside" of the arc, we use the same center point, but decrease the radius of the arc by the radius of the circle (half the width of the view).

    enter image description here

    enter image description here

    If you want to use that approach (rather than rotating a view with the circle in the corner), here is some example code.

    Start with our extensions, an enum, and a "small circle view":

    extension CGPoint {
        static func pointOnCircle(center: CGPoint, radius: CGFloat, angle: CGFloat) -> CGPoint {
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)
            return CGPoint(x: x, y: y)
        }
    }
    extension CGFloat {
        var degreesToRadians: Self { self * .pi / 180 }
        var radiansToDegrees: Self { self * 180 / .pi }
    }
    
    enum FollowType: Int {
        case inside, center, outside
    }
    class SmallCircleView : UIView {
    
        override func layoutSubviews() {
            super.layoutSubviews()
            layer.cornerRadius = bounds.size.height / 2.0
        }
    }
    

    Next, a UIView subclass that will handle drawing the arc, adding the circle subview, and it will use a UIViewPropertyAnimator with key frames to make it interactive:

    class FollowArcView: UIView {
        
        public var circleColor: UIColor = .red {
            didSet {
                circleView.backgroundColor = circleColor
            }
        }
        public var arcColor: UIColor = .blue { didSet { setNeedsLayout() } }
        public var arcLineWidth: CGFloat = 1 { didSet { setNeedsLayout() } }
    
        public var arcInset: CGFloat = 0 { didSet { setNeedsLayout() } }
        public var circleRadius: CGFloat = 25 { didSet { setNeedsLayout() } }
        
        public var followType: FollowType = .inside { didSet { setNeedsLayout() } }
        
        public var fractionComplete: CGFloat = 0 {
            didSet {
                if animator != nil {
                    animator.fractionComplete = fractionComplete
                }
            }
        }
        
        private let circleView = SmallCircleView()
        private let arcLayer = CAShapeLayer()
        
        private var animator: UIViewPropertyAnimator!
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            
            arcLayer.fillColor = UIColor.clear.cgColor
            layer.addSublayer(arcLayer)
            
            circleView.frame = CGRect(x: 0, y: 0, width: circleRadius * 2.0, height: circleRadius * 2.0)
            
            circleView.backgroundColor = circleColor
            addSubview(circleView)
            
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
    
            var curFraction: CGFloat = 0
            
            // we may be changing properties (such as arc color, inset, etc)
            //  after we've moved the circleView, so
            //  save the current .fractionComplete
            if animator != nil {
                curFraction = animator.fractionComplete
                animator.stopAnimation(true)
            }
            
            // these properties can be changed after initial view setup
            arcLayer.lineWidth = arcLineWidth
            arcLayer.strokeColor = arcColor.cgColor
            circleView.frame.size = CGSize(width: circleRadius * 2.0, height: circleRadius * 2.0)
            
            let arcCenter = CGPoint(x: arcInset, y: arcInset)
            let arcRadius = bounds.width - (arcInset * 2.0)
            let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
            
            let pth = UIBezierPath(arcCenter: arcCenter, radius: arcRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
            arcLayer.path = pth.cgPath
            
            let p = CGPoint.pointOnCircle(center: arcCenter, radius: followRadius, angle: CGFloat(90).degreesToRadians)
            circleView.center = p
    
            // the animator will take the current position of the circleView
            //  as Frame 0, so we need to let UIKit update the circleView's position
            //  before setting up the animator
            DispatchQueue.main.async {
                self.setupAnim()
                self.animator.fractionComplete = curFraction
            }
    
        }
        
        private func setupAnim() {
            
            if animator != nil {
                animator.stopAnimation(true)
            }
    
            // starting point
            var startDegrees: CGFloat = 90
            // ending point
            let endDegrees: CGFloat = 0
            
            // we'll be using percentages
            let numSteps: CGFloat = 100
            
            let arcCenter = CGPoint(x: arcInset, y: arcInset)
            let arcRadius = bounds.width - (arcInset * 2.0)
            let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
    
            animator = UIViewPropertyAnimator(duration: 0.3, curve: .linear)
            
            animator.addAnimations {
                UIView.animateKeyframes(withDuration: 0.1, delay: 0.0, animations: {
                    let stepDegrees: Double = (startDegrees - endDegrees) / Double(numSteps)
                    for i in 1...Int(numSteps) {
                        // decrement degrees by step value
                        startDegrees -= stepDegrees
                        // get point on discPathRadius circle
                        let p = CGPoint.pointOnCircle(center: arcCenter, radius: followRadius, angle: startDegrees.degreesToRadians)
                        // duration is 1 divided by number of steps
                        let duration = 1.0 / Double(numSteps)
                        // start time for this frame is duration * this step
                        let startTime = duration * Double(i)
                        // add the keyframe
                        UIView.addKeyframe(withRelativeStartTime: startTime, relativeDuration: duration) {
                            self.circleView.center = p
                        }
                    }
                })
            }
            
            // start and immediately pause the animation
            animator.startAnimation()
            animator.pauseAnimation()
            
        }
        
    }
    

    and an example controller class:

    class FollowArcVC: UIViewController {
        
        let followArcView = FollowArcView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let slider = UISlider()
            let typeControl = UISegmentedControl(items: ["Inside", "Center", "Outside"])
    
            [followArcView, typeControl, slider].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                followArcView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                followArcView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                followArcView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                // 1:1 ratio (square)
                followArcView.heightAnchor.constraint(equalTo: followArcView.widthAnchor),
                
                typeControl.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                typeControl.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                typeControl.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
    
                slider.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
                slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                
            ])
        
            typeControl.selectedSegmentIndex = 0
            
            typeControl.addTarget(self, action: #selector(typeChanged(_:)), for: .valueChanged)
            slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
    
            // if we want to see the view frame
            //followArcView.backgroundColor = .yellow
            
        }
    
        @objc func typeChanged(_ sender: UISegmentedControl) -> Void {
            switch sender.selectedSegmentIndex {
            case 0:
                followArcView.followType = .inside
            case 1:
                followArcView.followType = .center
            case 2:
                followArcView.followType = .outside
            default:
                ()
            }
        }
    
        @objc func sliderChanged(_ sender: Any?) {
            guard let sldr = sender as? UISlider else { return }
            followArcView.fractionComplete = CGFloat(sldr.value)
        }
    
    }
    

    The result:

    enter image description here


    Edit

    After playing around a bit, this is another way to interactively follow the path — it uses layer path animation, and avoids the need to manually calculate keyframe positions.

    Works with the same sample view controller as above – just replace the FollowArcView class:

    class FollowArcView: UIView {
        
        public var circleColor: UIColor = .red {
            didSet {
                circleView.backgroundColor = circleColor
            }
        }
        public var arcColor: UIColor = .blue {
            didSet {
                arcLayer.strokeColor = arcColor.cgColor
            }
        }
        public var arcLineWidth: CGFloat = 1 {
            didSet {
                arcLayer.lineWidth = arcLineWidth
            }
        }
    
        public var arcInset: CGFloat = 0 { didSet { setNeedsLayout() } }
        public var circleRadius: CGFloat = 25 { didSet { setNeedsLayout() } }
        
        public var followType: FollowType = .inside { didSet { setNeedsLayout() } }
        
        public var fractionComplete: CGFloat = 0 {
            didSet {
                circleView.layer.timeOffset = CFTimeInterval(fractionComplete)
            }
        }
        
        private let circleView = SmallCircleView()
        private let arcLayer = CAShapeLayer()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            
            arcLayer.fillColor = UIColor.clear.cgColor
            arcLayer.lineWidth = arcLineWidth
            arcLayer.strokeColor = arcColor.cgColor
            layer.addSublayer(arcLayer)
            
            circleView.frame = CGRect(x: 0, y: 0, width: circleRadius * 2.0, height: circleRadius * 2.0)
            
            circleView.backgroundColor = circleColor
            addSubview(circleView)
            
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            circleView.frame.size = CGSize(width: circleRadius * 2.0, height: circleRadius * 2.0)
            
            let arcCenter = CGPoint(x: arcInset, y: arcInset)
            let arcRadius = bounds.width - (arcInset * 2.0)
    
            let pth = UIBezierPath(arcCenter: arcCenter, radius: arcRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
            arcLayer.path = pth.cgPath
            
            self.setupAnim()
            
        }
        
        private func setupAnim() {
            
            let arcCenter = CGPoint(x: arcInset, y: arcInset)
            let arcRadius = bounds.width - (arcInset * 2.0)
            let followRadius = followType == .inside ? arcRadius - circleRadius : followType == .center ? arcRadius : arcRadius + circleRadius
            
            let pth = UIBezierPath(arcCenter: arcCenter, radius: followRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
    
            let animation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
            
            animation.duration = 1
            animation.fillMode = .forwards
            animation.isRemovedOnCompletion = false
            
            animation.path = pth.cgPath
            
            circleView.layer.speed = 0
            circleView.layer.timeOffset = 0
            
            circleView.layer.add(animation, forKey: "PathAnim")
            
            DispatchQueue.main.async {
                self.circleView.layer.timeOffset = self.fractionComplete
            }
            
        }
        
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search