0

I built a custom view as a subclass of UIView. Now, I'd like to set the constraints for this view in my ViewController instead of setting the frame. This is the custom view class:

PullUpView.swift

//
//  PullUpView.swift
//  
//

import UIKit

final internal class PullUpView: UIView, UIGestureRecognizerDelegate {

    // MARK: - Variables
    private var animator                : UIDynamicAnimator!
    private var collisionBehavior       : UICollisionBehavior!
    private var snapBehaviour           : UISnapBehavior?
    private var dynamicItemBehavior     : UIDynamicItemBehavior!
    private var gravityBehavior         : UIGravityBehavior!
    private var panGestureRecognizer    : UIPanGestureRecognizer!
    internal var delegate               : PullUpViewDelegate?


    // MARK: - Setup the view when bounds are defined.
    override var bounds: CGRect {
        didSet {
            print(self.frame)
            print(self.bounds)
            setupStyle()
            setupBehavior()
            setupPanGesture()
        }
    }

    override var frame: CGRect {
        didSet {
            print(self.frame)
            print(self.bounds)
            setupStyle()
            setupBehavior()
            setupPanGesture()
        }
    }
}


// MARK: - Behavior (Collision, Gravity, etc.)
private extension PullUpView {
    final private func setupBehavior() {
        guard let superview = superview else { return }
        animator = UIDynamicAnimator(referenceView: superview)
        dynamicItemBehavior = UIDynamicItemBehavior(items: [self])
        dynamicItemBehavior.allowsRotation = false
        dynamicItemBehavior.elasticity = 0

        gravityBehavior = UIGravityBehavior(items: [self])
        gravityBehavior.gravityDirection = CGVector(dx: 0, dy: 1)

        collisionBehavior = UICollisionBehavior(items: [self])

        configureContainer()

        animator.addBehavior(gravityBehavior)
        animator.addBehavior(dynamicItemBehavior)
        animator.addBehavior(collisionBehavior)
    }

    final private func configureContainer() {
        let boundaryWidth = UIScreen.main.bounds.size.width
        let boundaryHeight = UIScreen.main.bounds.size.height

        collisionBehavior.addBoundary(withIdentifier: "upper" as NSCopying, from: CGPoint(x: 0, y: 0), to: CGPoint(x: boundaryWidth, y: 0))

        collisionBehavior.addBoundary(withIdentifier: "lower" as NSCopying, from: CGPoint(x: 0, y: boundaryHeight + self.frame.height - 66), to: CGPoint(x: boundaryWidth, y: boundaryHeight + self.frame.height - 66))
    }

    final private func snapToBottom() {
        gravityBehavior.gravityDirection = CGVector(dx: 0, dy: 2.5)
    }

    final private func snapToTop() {
        gravityBehavior.gravityDirection = CGVector(dx: 0, dy: -2.5)
    }
}


// MARK: - Style (Corner-radius, background, etc.)
private extension PullUpView {
    final private func setupStyle() {
        self.backgroundColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0)
        let blurEffect = UIBlurEffect(style: .regular)
        let blurEffectView = UIVisualEffectView(effect: blurEffect)
        blurEffectView.frame = self.bounds
        blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.addSubview(blurEffectView)

        self.layer.masksToBounds = true
        self.clipsToBounds = true
        self.isUserInteractionEnabled = true

        let dragBar = UIView()
        dragBar.backgroundColor = .gray
        dragBar.frame = CGRect(x: (self.bounds.width / 2) - 5, y: self.bounds.origin.y + 5, width: 10, height: 2)
        self.addSubview(dragBar)
    }
}



// MARK: - Pan Gesture Recognizer and Handler Functions
internal extension PullUpView {
    final private func setupPanGesture() {
        panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        panGestureRecognizer.cancelsTouchesInView = false
        self.addGestureRecognizer(panGestureRecognizer)
    }

    @objc final private func handlePanGesture(_ panGestureRecognizer: UIPanGestureRecognizer) {
        guard let superview = superview else { return }
        if let viewCanMove: Bool = delegate?.viewCanMove?(pullUpView: self) {
            if !viewCanMove {
                return
            }
        }

        let velocity = panGestureRecognizer.velocity(in: superview).y

        var movement = self.frame
        movement.origin.x = 0
        movement.origin.y = movement.origin.y + (velocity * 0.05)

        if panGestureRecognizer.state == .ended {
            panGestureEnded()
        } else if panGestureRecognizer.state == .began {
            snapToBottom()
        } else {
            animator.removeBehavior(snapBehaviour ?? UIPushBehavior())
            snapBehaviour = UISnapBehavior(item: self, snapTo: CGPoint(x: movement.midX, y: movement.midY))
            animator.addBehavior(snapBehaviour ?? UIPushBehavior())
        }
    }

    final private func panGestureEnded() {
        animator.removeBehavior(snapBehaviour ?? UIPushBehavior())

        let velocity = dynamicItemBehavior.linearVelocity(for: self)

        if fabsf(Float(velocity.y)) > 250 {
            if velocity.y < 0 {
                moveViewIfPossible(direction: .up)
            } else {
                moveViewIfPossible(direction: .down)
            }
        } else {
            if let superviewHeight = self.superview?.bounds.size.height {
                // User scrolled over half of the screen...
                if self.frame.origin.y > superviewHeight / 2 {
                    moveViewIfPossible(direction: .down)
                } else {
                    moveViewIfPossible(direction: .up)
                }
            }
        }
    }

    final private func moveViewIfPossible(direction: PullUpViewPanDirection) {
        if let viewCanMove: Bool = delegate?.viewCanSnap?(pullUpView: self, direction: direction) {
            if viewCanMove {
                delegate?.viewWillMove?(pullUpView: self, direction: direction)
                direction == .up ? snapToTop() : snapToBottom()
            }
        } else {
            delegate?.viewWillMove?(pullUpView: self, direction: direction)
            direction == .up ? snapToTop() : snapToBottom()
        }
    }
}


// MARK: - PullUpViewPanDirection declared inside
internal extension PullUpView {
    /// An enum that defines whether the view moves up or down.
    @objc
    internal enum PullUpViewPanDirection: Int {
        case
        up,
        down
    }
}

But when I set the constraints, I always get a frame with a negative origin:

ViewController.swift

pullUpView.translatesAutoresizingMaskIntoConstraints = false
pullUpView.widthAnchor.constraint(equalTo: self.view.widthAnchor).isActive = true
pullUpView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
pullUpView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
pullUpView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true

Frame:
(-160.0, -252.0, 320.0, 504.0)

Bounds:
(0.0, 0.0, 320.0, 504.0)

What is strange is that if I comment out the function setupBehavior(), the view is displayed correctly (but it will fail as soon as the pan gesture handler is triggered).

Instead, if I set the frame:

pullUpView.frame = CGRect(x: 0, y: self.view.bounds.height - 66, width: self.view.bounds.width, height: self.view.bounds.height)

it works fine.

Thanks for any help in advance!

j3141592653589793238
  • 1,810
  • 2
  • 16
  • 38
  • 1
    I think your first mistake is thinking about *frames* when using auto layout. Simply put, they don't work together. Second, just *use* the view life cycle - not *abuse* it. It may appear like solid logic to `override` bounds and frames, but why? If you want to use constraints just accept that the OS will do it's thing. Set your constraints in `viewDidLoad` without worrying about bounds and frames... accept that by time `viewWillLayoutSubviews` and `viewDidLayoutSubviews` things will be there. Overriding `didSet` on bounds and frames? Totally wrong things to do when using auto layout. –  Feb 17 '18 at 15:42
  • @dfd But how and where would I call the setup***() functions if I wouldn't override the frame or bounds? Could you possibly write an answer? – j3141592653589793238 Feb 17 '18 at 15:50
  • 1
    I'm not sure what you mean by "setup***() functions". I'll give you an example as an answer - the markdown may help - if the answer isn't helpful, let me know I'll delete it. –  Feb 17 '18 at 16:29
  • Thank you! I was talking about setupStyle(), setupBehavior() and setupPanGesture(). They are called within didSet(). – j3141592653589793238 Feb 17 '18 at 16:31
  • 1
    I focused on "constraints" because of your question title. But from what I see I think most everything could simply be done in `viewDidLoad` because you really want to do these things *once* and not multiple times. –  Feb 17 '18 at 16:54

1 Answers1

2

Looking at your constraints you are trying to set, it appears you want a view controller - let's call it MainViewController - to have a custom view as it's full screen view.

The first thing you should do when using auto layout is forget about frames. In most cases, forget about bounds. Next, I'd use the view life cycle and forget about using didSet on bounds and frames. Here's how I'd do it:

Set everything you possibly can in viewDidLoad - and in your case, it's everything:

override func viewDidLoad() {
    super.viewDidLoad()

    pullUpView.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(pullUpView)

    pullUpView.widthAnchor.constraint(equalTo: self.view.widthAnchor).isActive = true
    pullUpView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
    pullUpView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
    pullUpView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
}

That's about it! Relevant notes:

  • You properly set the auto resizing mask to false. Good job. If you don't you'll get constraint conflict. More importantly, if you do you've basically have no need to worry about bounds or frames.
  • The auto resizing mask is automatically set to false if you use IB.
  • If you use anchors and wish to make your custom view have either (a) a 10 point border or (b) be 50% width/height, use a constant: 10 or a multiplier: 0.5.
  • Not required, but it helps keep away from view controller size - move your constraints into it's own subroutine (I call mine setUpConstraints() and make it an extension to your view controller.

Last point, and from what I gleaned from an email from Apple iTunesConnect is going to be demanded come April, use the "safe area" for all your devices. Here's what I do:

let safeAreaView = UIView()

safeAreaView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(safeAreaView)

if #available(iOS 11, *) {
    let guide = view.safeAreaLayoutGuide
    safeAreaView.topAnchor.constraintEqualToSystemSpacingBelow(guide.topAnchor, multiplier: 1.0).isActive = true
    safeAreaView.bottomAnchor.constraintEqualToSystemSpacingBelow(guide.bottomAnchor, multiplier: 1.0).isActive = true
} else {
    safeAreaView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
    safeAreaView.bottomAnchor.constraint(equalTo: bottomLayoutGuide.topAnchor).isActive = true
}
safeAreaView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
safeAreaView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true

There are other ways to do this (this creates a UIView) but now you set all your constraints against safeAreaView instead of self.view.

Final note: if you must reference a frame, do it as late as possible in the view controller view life-cycle, say viewDidLayoutSubview. While the bounds may be known as early as viewDidLoad, frames aren't.

I'll bet that if you traced how many time the frame didSet was happening on your average view load or orientation change, you'll find it more than you thought.