Programmatic hide/unhide edges of UIScrollView in Swift while using autolayout

637 views Asked by At

I'm trying to convert some ObjC for a horizontally scrolling UITableViewCell to Swift. The original ObjC version used explicit sizing of the cell contents but I am trying to use autolayout for consistency across my app.

All was going well up until the point where I wanted to create buttons on the left and right of the screen that can be hidden/unhidden by swiping in the same way as the delete button in the Mail app (but I want a bit more functionality hence a homebrew solution). The original code achieved this by creating scrollview content insets at the left and right that were the same size as the buttons but I have a few conceptual and programmatic issues with this.

Firstly, I understood contentInset as a 'buffer' zone around a scroll view that people often use to prevent the content area being hidden by status or navigation bars (i.e. a way of maximising screen real-estate). As such, the content area of the scrollview should remain constant regardless of contentInsets as they are essentially a margin. However, the original coder creates a negative insets and these seem to magically increase content area but the views within the insets are hidden off-screen. What's going on? Why do we do this rather than just changing the offset to compensate for the width of the off-screen buttons?

Secondly, buttons are revealed by swiping far or fast enough so that the eventual resting point of the scrollview reveals the hidden button. The code to lock the scrollview in position and stop the button from being re-hidden is within the scrollViewWillEndDragging function and changes the targetContentOffset to achieve this. This works when using explicit view and button sizes but fails to work when using autolayout. However, if you call scrollview.setContentOffset it works. Why the difference? Surely it's the same thing but I'm guessing there must be a different sequence of method calls.

Code is below (I've edited out some of the less important material). This is proof-of-concept so not very elegant!

Creating scrollView:

        let scrollView = UIScrollView()
        scrollView.backgroundColor = UIColor.blueColor()
        scrollView.delegate = self;
        scrollView.scrollsToTop = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.showsVerticalScrollIndicator = false
        self.contentView.addSubview(scrollView)
        // pin scrollView to cell contentView
        scrollView.setTranslatesAutoresizingMaskIntoConstraints(false)
        self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[sv]|", options: nil, metrics: nil, views: ["sv" : scrollView]))
        self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[sv]|", options: nil, metrics: nil, views: ["sv" : scrollView]))

        let scrollViewContent = UIView()
        scrollView.addSubview(scrollViewContent)

        // add content sequentially (L>>R) to the content subview of scrollView
        var lastObjectAdded: UIView? = nil // allows constraints to be created between adjacent contentView subviews

        // add button on the left
        let leftButton = UIButton.buttonWithType(UIButtonType.Custom) as! UIButton
        leftButton.backgroundColor = UIColor.purpleColor()
        leftButton.setTitle("Click Me!", forState: UIControlState.Normal)
        leftButton.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Normal)
        // width needs to be set explicitly so inset for scroll view can be set in this class
        // (when using autolayout, frame dimensions are not available until after all subviews are laid out)
        leftButton.frame.size.width = CGFloat(100)
        scrollViewContent.addSubview(leftButton)
        // size using autolayout
        leftButton.setTranslatesAutoresizingMaskIntoConstraints(false)
        // give button a fixed width
        // TODO: Enable this to be defined by function call
        scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[but(==100)]", options: nil, metrics: nil, views: ["but" : leftButton]))
        // pin height to height of cell's contentView
        self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:[but(==cv)]", options: nil, metrics: nil, views: ["but" : leftButton, "cv" : self.contentView]))
        // pin to top left of the scollView subview
        scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[but]", options: nil, metrics: nil, views: ["but" : leftButton]))
        scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[but]", options: nil, metrics: nil, views: ["but" : leftButton]))
        // store a reference to the button to be used for positioning other subviews
        lastObjectAdded = leftButton
        testView = leftButton

< SNIP ---
--- SNIP>

        // test that I can add a label that matches the  width of the tableViewCell's contentView subview'
        let specialLab = UILabel()
        specialLab.setTranslatesAutoresizingMaskIntoConstraints(false)
        specialLab.backgroundColor = UIColor.orangeColor()
        specialLab.text = "WIDTH MATCHES TABLEVIEWCELL WIDTH"
        scrollViewContent.addSubview(specialLab)
        scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-(10)-[lab]", options: nil, metrics: nil, views: ["lab" : specialLab]))
        // this is the important constraint
        self.contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[lab(==cv)]", options: nil, metrics: nil, views: ["lab" : specialLab, "cv" : contentView]))
        scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[last]-(10)-[lab]", options: nil, metrics: nil, views: ["lab" : specialLab, "last" : lastObjectAdded!]))
        lastObjectAdded = specialLab

        // pin last label to right which dictates content size width
        scrollViewContent.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:[lab]-10-|", options: nil, metrics: nil, views: ["lab" : lastObjectAdded!]))

        // set scrollView's content view height and frame-to-superview constraints
        // (width comes from subview constraints)
        // content view is calculated for us
        scrollViewContent.setTranslatesAutoresizingMaskIntoConstraints(false)
        scrollView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[svc]|", options: nil, metrics: nil, views: ["svc" : scrollViewContent]))
        scrollView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|[svc]|", options: nil, metrics: nil, views: ["svc" : scrollViewContent]))

        // create inset to hide button on left
         scrollView.contentInset = UIEdgeInsetsMake(0, -leftButton.bounds.width, 0, 0)

The second bit of code relates to un-hiding the button on the left:

extension SwipeableViewCell: UIScrollViewDelegate {

    func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        let left = CGFloat(100.0) // this is the inset required to hide the left button(s)
        let kSwipeableTableViewCellOpenVelocityThreshold = CGFloat(0.6) // minimum velocity req to reveal buttons
        let kSwipeableTableViewCellMaxCloseMilliseconds = CGFloat(300) // max time for the button to be hidden
        let x = scrollView.contentOffset.x

        // check to see if swipe from left is far enough and fast enough to reveal button
        if (left > 0 && (x < 0 || (x < left && velocity.x < -kSwipeableTableViewCellOpenVelocityThreshold))) {
            // manually set the offset to reveal the whole button but no more
//            targetContentOffset.memory = CGPointZero // this should work but doesn't - offset is not retained
            dispatch_async(dispatch_get_main_queue()) {
                scrollView.setContentOffset(CGPointZero, animated: true) // this works!
            }
        } else {
            // if not, hide the button
            dispatch_async(dispatch_get_main_queue()) {
                scrollView.setContentOffset(CGPoint(x: CGFloat(100), y: CGFloat(0)), animated: true)
            }
        }
    }
}
0

There are 0 answers