简体   繁体   中英

UICollisionBehavior detection between round subViews in square parentViews

I have a square containerView with a roundImageView inside of it. The containerView is added to the UIDynamicAnimator. When the corners of the containerViews collide off of each other I need them to bounce off of the roundImageView, same as this question . Inside the the customContainerView I override collisionBoundsType... return.ellipse but the collision is still occurs from the square and not the circle, and the views are overlapping each other.

在此处输入图像描述

在此处输入图像描述

customView:

class CustomContainerView: UIView {
    
    override public var collisionBoundsType: UIDynamicItemCollisionBoundsType {
        return .ellipse
    }
}

code:

var arr = [CustomContainerView]()

var animator: UIDynamicAnimator!
var gravity: UIGravityBehavior!
var collider: UICollisionBehavior!
var bouncingBehavior  : UIDynamicItemBehavior!

override func viewDidLoad() {
    super.viewDidLoad()
    
    addSubViews()
    
    addAnimatorAndBehaviors()
}

func addAnimatorAndBehaviors() {
    
    animator = UIDynamicAnimator(referenceView: self.view)
    
    gravity = UIGravityBehavior(items: arr)
    animator.addBehavior(gravity)
    
    collider = UICollisionBehavior(items: arr)
    collider.translatesReferenceBoundsIntoBoundary = true
    animator.addBehavior(collider)
    
    bouncingBehavior = UIDynamicItemBehavior(items: arr)
    bouncingBehavior.elasticity = 0.05
    animator.addBehavior(bouncingBehavior)
}

func addSubViews() {
    
    let redView = createContainerView(with: .red)
    let blueView = createContainerView(with: .blue)
    let yellowView = createContainerView(with: .yellow)
    let purpleView = createContainerView(with: .purple)
    let greenView = createContainerView(with: .green)
    
    view.addSubview(redView)
    view.addSubview(blueView)
    view.addSubview(yellowView)
    view.addSubview(purpleView)
    view.addSubview(greenView)
    
    arr = [redView, blueView, yellowView, purpleView, greenView]
}

func createContainerView(with color: UIColor) -> UIView {
    
    let containerView = CustomContainerView()
    containerView.backgroundColor = .brown
    
    let size = CGSize(width: 50, height: 50)
    
    containerView.frame.size = size
    containerView.center = view.center

    let roundImageView = UIImageView()
    roundImageView.translatesAutoresizingMaskIntoConstraints = false
    roundImageView.backgroundColor = color
    
    containerView.addSubview(roundImageView)
    
    roundImageView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
    roundImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
    roundImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10).isActive = true
    roundImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true
    
    roundImageView.layer.masksToBounds = true
    roundImageView.layoutIfNeeded()
    roundImageView.layer.cornerRadius = roundImageView.frame.height / 2

    roundImageView.layer.borderWidth = 1
    roundImageView.layer.borderColor = UIColor.white.cgColor

    return containerView
}

Looks like collision behavior doesn't like .ellipse type when the views are positioned exactly on top of each other.

Running your code a few times gives different results (as expected)... sometimes, all 5 views end up in a full vertical stack, other times it ends up with some overlap, and other times (after waiting a few seconds) the views settle with a couple visible and the others way below the bottom of the view - I've seen their y-positions get to > 40,000.

I made a few modifications to your code to see what's happening...

I added more views and gave each one a shape layer showing the ellipse bounds.

Then, instead of starting with them all at identical positions, I created a couple "rows" so it looks like this:

在此处输入图像描述

Then, on each tap, I reset the original positions and toggle the UIDynamicItemCollisionBoundsType between ellipse and rectangle , and then call addAnimatorAndBehaviors() again.

Here's how it looks on sample .ellipse run:

在此处输入图像描述

and on sample .rectangle run:

在此处输入图像描述

As we can see, the .ellipse bounds are being used.

Here's the code I used to play with this:

class CustomContainerView: UIView {
    var useEllipse: Bool = false
    
    override public var collisionBoundsType: UIDynamicItemCollisionBoundsType {
        return useEllipse ? .ellipse : .rectangle
    }
}

class ViewController: UIViewController {
    
    var arr = [CustomContainerView]()
    
    var animator: UIDynamicAnimator!
    var gravity: UIGravityBehavior!
    var collider: UICollisionBehavior!
    var bouncingBehavior  : UIDynamicItemBehavior!
    
    let infoLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addSubViews()

        // add info label
        infoLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(infoLabel)
        infoLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        infoLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        
        // add a tap recognizer to start the Animator Behaviors
        let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
        view.addGestureRecognizer(t)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        positionViews()
    }
    
    func positionViews() -> Void {
        // let's make rows of the views,
        //  instead of starting with them all on top of each other
        // we'll do 3-views over 2-views
        
        let w = arr[0].frame.width * 1.1
        let h = arr[0].frame.height * 1.1

        var x: CGFloat = 0
        var y: CGFloat = 0

        var idx: Int = 0
        
        y = h
        
        while idx < arr.count {
            x = view.center.x - w
            for _ in 1...3 {
                if idx < arr.count {
                    arr[idx].center = CGPoint(x: x, y: y)
                }
                x += w
                idx += 1
            }
            y += h
            x = view.center.x - w * 0.5
            for _ in 1...2 {
                if idx < arr.count {
                    arr[idx].center = CGPoint(x: x, y: y)
                }
                x += w
                idx += 1
            }
            y += h
        }

    }
    
    @objc func gotTap(_ g: UIGestureRecognizer) -> Void {
        positionViews()
        arr.forEach { v in
            v.useEllipse.toggle()
        }
        infoLabel.text = arr[0].useEllipse ? "Ellipse" : "Rectangle"
        addAnimatorAndBehaviors()
    }
    
    func addAnimatorAndBehaviors() {
        
        animator = UIDynamicAnimator(referenceView: self.view)
        
        gravity = UIGravityBehavior(items: arr)
        animator.addBehavior(gravity)
        
        collider = UICollisionBehavior(items: arr)
        collider.translatesReferenceBoundsIntoBoundary = true
        animator.addBehavior(collider)
        
        bouncingBehavior = UIDynamicItemBehavior(items: arr)
        bouncingBehavior.elasticity = 0.05
        animator.addBehavior(bouncingBehavior)
    }
    
    func addSubViews() {

        let clrs: [UIColor] = [
            .red, .green, .blue,
            .purple, .orange,
            .cyan, .yellow, .magenta,
            .systemTeal, .systemGreen,
        ]

        clrs.forEach { c in
            let v = createContainerView(with: c)
            view.addSubview(v)
            arr.append(v)
        }
        
    }
    
    func createContainerView(with color: UIColor) -> CustomContainerView {
        
        let containerView = CustomContainerView()
        containerView.backgroundColor = UIColor.brown.withAlphaComponent(0.2)
        
        let size = CGSize(width: 50, height: 50)
        
        containerView.frame.size = size
        
        view.addSubview(containerView)
        
        let roundImageView = UIImageView()
        roundImageView.translatesAutoresizingMaskIntoConstraints = false
        roundImageView.backgroundColor = color
        
        containerView.addSubview(roundImageView)
        
        roundImageView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
        roundImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
        roundImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10).isActive = true
        roundImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true

        roundImageView.layer.masksToBounds = true
        roundImageView.layoutIfNeeded()
        roundImageView.layer.cornerRadius = roundImageView.frame.height / 2
        
        roundImageView.layer.borderWidth = 1
        roundImageView.layer.borderColor = UIColor.white.cgColor
        
        // let's add a CAShapeLayer to show the ellipse bounds
        let c = CAShapeLayer()
        c.fillColor = UIColor.clear.cgColor
        c.lineWidth = 1
        c.strokeColor = UIColor.black.cgColor
        c.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).cgPath
        containerView.layer.addSublayer(c)
        
        return containerView
    }
    
}

Edit

Change the while loop in positionViews() to this... tap to reset and run the animation a number of times and see what happens when all the views start with the same frame:

        while idx < arr.count {
            x = view.center.x - w
            arr[idx].center = CGPoint(x: x, y: y)
            idx += 1
        }

Then, use this while loop, where we start the views at the same x-position, but increment the y-position for each view (just by 0.1 points):

        while idx < arr.count {
            x = view.center.x - w
            // increment the y position for each view -- just a tad
            y += 0.1
            arr[idx].center = CGPoint(x: x, y: y)
            idx += 1
        }

Another Edit

Worth noting, the fact that the ellipse collision bounds is round ( 1:1 ratio), also affects things.

If we change the size of the view frames just slightly , we get very different results.

Try it with:

let size = CGSize(width: 50.1, height: 50)

and start them all with the exact same center point:

while idx < arr.count {
    x = view.center.x - w
    arr[idx].center = CGPoint(x: x, y: y)
    idx += 1
}

and you'll see the views spread out immediately.


One more Edit - to help visualize the differences

Here's another example - this time, I've numbered the views and set a "every 1/10th second" timer to update a label with the current center of each view.

Also added segmented controls to select collisionBoundsType and overlaying the views exactly on top of each other or offsetting them slightly:

class CustomContainerView: UIView {
    var useEllipse: Bool = false
    override public var collisionBoundsType: UIDynamicItemCollisionBoundsType {
        return useEllipse ? .ellipse : .rectangle
    }
}

// extension to left-pad a string up-to length
extension RangeReplaceableCollection where Self: StringProtocol {
    func paddingToLeft(upTo length: Int, using element: Element = " ") -> SubSequence {
        return repeatElement(element, count: Swift.max(0, length-count)) + suffix(Swift.max(count, count-length))
    }
}

class CollisionVC: UIViewController {
    
    var arr = [CustomContainerView]()
    
    var animator: UIDynamicAnimator!
    var gravity: UIGravityBehavior!
    var collider: UICollisionBehavior!
    var bouncingBehavior: UIDynamicItemBehavior!
    
    let infoLabel = UILabel()
    
    // add segmented controls for collisionBoundsType and "Spread Layout"
    let seg1 = UISegmentedControl(items: ["Ellipse", "Rectangle"])
    let seg2 = UISegmentedControl(items: ["Overlaid", "Offset"])
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addSubViews()
        
        [seg1, seg2, infoLabel].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        infoLabel.numberOfLines = 0
        infoLabel.font = .monospacedSystemFont(ofSize: 14.0, weight: .light)

        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            seg1.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
            seg1.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
            
            seg2.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
            seg2.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
            
            infoLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
        ])
        
        seg1.selectedSegmentIndex = 0
        seg2.selectedSegmentIndex = 0

        // add a tap recognizer to start the Animator Behaviors
        let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
        view.addGestureRecognizer(t)
        
        // run a Timer... every 1/10th second we'll fill the infoLabel with
        //  collisionBoundsType and a list of center points
        //  for all subviews
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
            if self.animator != nil {
                var s = ""
                for i in 0..<self.arr.count {
                    let c = self.arr[i].center
                    let xs = String(format: "%0.2f", c.x)
                    let ys = String(format: "%0.2f", c.y)
                    s += "\n\(i) - x: \(String(xs.paddingToLeft(upTo: 7))) y: \(String(ys.paddingToLeft(upTo: 9)))"
                }
                s += "\nAnimator is running: " + (self.animator.isRunning ? "Yes" : "No")
                self.infoLabel.text = s
            }
        }
        
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        positionViews()
    }
    
    func positionViews() -> Void {

        var x: CGFloat = 0.0
        var y: CGFloat = 0.0
        arr.forEach { v in
            v.center = CGPoint(x: view.center.x + x, y: view.safeAreaInsets.top + 100.0 + y)
            
            // if seg2 == Overlaid, position all views exactly on top of each other
            // else, Offset the x,y center of each one by 0.1 pts
            //  Offsetting them allows the animator to use
            //  "valid" collision adjustments on start
            if seg2.selectedSegmentIndex == 1 {
                x += 0.1
                y += 0.1
            }

            // set collisionBoundsType
            v.useEllipse = seg1.selectedSegmentIndex == 0
        }
        
    }
    
    @objc func gotTap(_ g: UIGestureRecognizer) -> Void {
        positionViews()
        addAnimatorAndBehaviors()
    }
    
    func addAnimatorAndBehaviors() {
        animator = UIDynamicAnimator(referenceView: self.view)
        
        gravity = UIGravityBehavior(items: arr)
        animator.addBehavior(gravity)
        
        collider = UICollisionBehavior(items: arr)
        collider.translatesReferenceBoundsIntoBoundary = true
        animator.addBehavior(collider)
        
        bouncingBehavior = UIDynamicItemBehavior(items: arr)
        bouncingBehavior.elasticity = 0.05
        animator.addBehavior(bouncingBehavior)
    }
    
    func addSubViews() {
        
        let clrs: [UIColor] = [
            .red, .green, UIColor(red: 1.0, green: 0.85, blue: 0.55, alpha: 1.0),
            UIColor(red: 1.0, green: 0.5, blue: 1.0, alpha: 1.0), .orange,
            .cyan, .yellow, .magenta,
            .systemTeal, .systemGreen,
        ]
        
        for (c, i) in zip(clrs, (0..<clrs.count)) {
            let v = createContainerView(with: c, number: i)
            view.addSubview(v)
            arr.append(v)
        }
        
    }
    
    func createContainerView(with color: UIColor, number: Int) -> CustomContainerView {
        
        let containerView = CustomContainerView()
        containerView.backgroundColor = UIColor.brown.withAlphaComponent(0.2)
        
        let size = CGSize(width: 50, height: 50)
        
        containerView.frame.size = size
        
        view.addSubview(containerView)
        
        let roundLabel = UILabel()
        roundLabel.translatesAutoresizingMaskIntoConstraints = false
        roundLabel.backgroundColor = color
        roundLabel.text = "\(number)"
        roundLabel.textAlignment = .center
        
        containerView.addSubview(roundLabel)
        
        roundLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
        roundLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
        roundLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10).isActive = true
        roundLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true
        
        roundLabel.layer.masksToBounds = true
        roundLabel.layoutIfNeeded()
        roundLabel.layer.cornerRadius = roundLabel.frame.height / 2
        
        roundLabel.layer.borderWidth = 1
        roundLabel.layer.borderColor = UIColor.white.cgColor
        
        // let's add a CAShapeLayer to show the ellipse bounds
        let c = CAShapeLayer()
        c.fillColor = UIColor.clear.cgColor
        c.lineWidth = 1
        c.strokeColor = UIColor.black.cgColor
        c.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).cgPath
        containerView.layer.addSublayer(c)
        
        return containerView
    }
    
}

Worth noting: when the collisionBoundsType ==.ellipse and the views start exactly on top of each other, the collision algorithm can (and usually does) end up pushing a couple views off the bottom, which puts them outside the reference system's bounds . At that point, the algorithm continues trying to collide those views, pushing them further and further down o the Y axis.

Here is the output after letting it run for a few seconds:

在此处输入图像描述

Views 5, 7 and 8 are way out of bounds, and the animator is still running. Those views will continue to be pushed further and further down, presumably until we get an invalid point crash (I haven't let it run long enough to find out).

Also, because the animator ends up doing so much processing on those out-of-bounds views, the collision detection on the remaining views suffers.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM