Home > Net >  Ball path not following semi-circle exactly
Ball path not following semi-circle exactly

Time:01-07

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)
}


}

CodePudding user response:

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

CodePudding user response:

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

  •  Tags:  
  • Related