Home > Software engineering >  UIScrollView: Lock scrolling to vertical axis when zoomed in without changing the contentView
UIScrollView: Lock scrolling to vertical axis when zoomed in without changing the contentView

Time:01-06

What’s the best way to dynamically lock scrolling in a UIScrollView to the vertical axis depending on the zoom scale? I want to allow scrolling a large canvas in any direction when zoomed out (scrollView.zoomScale < 1.0) but prevent horizontal scrolling completely when zoomed in (scrollView.zoomScale == 1.0).

The challenge here is that UIScrollView doesn’t seem to have a built-in setting to limit scrolling to one direction if the contentView is larger than the viewport in both directions. I would like to use the same large contentView but disallow horizontal scrolling when zoomed in. (I know about scrollView.isDirectionalLockEnabled, but that’s not what I need: It only checks whether the user’s pan gesture has a dominant scrolling direction and then dynamically locks scrolling to either direction.)

Thanks!

CodePudding user response:

If I understand your goal correctly...

  • You have a "contentView" that is larger than the scroll view
  • if the zoom scale is 1.0, only allow vertical scrolling
  • if the zoom scale is less than 1.0, allow both vertical and horizontal scrolling

So, if we have a scroll view frame size of 388 x 661 and a "contentView" with a size of 2100 x 2100, we start like this at zoom scale 1.0 (the bright-green is the scroll view frame):

enter image description here

and only vertical scrolling is allowed.

If the user zooms-out to, say, 0.8 scale:

enter image description here

both vertical and horizontal scrolling is allowed.

If the user then zooms-in back to 1.0 scale:

enter image description here

we're back to only vertical scrolling.

You can accomplish that by conforming your controller to UIScrollViewDelegate, assign self as the scrollView's delegate, add a "last scrollView content offset X" var, and then implement scrollViewDidScroll():

var lastOffsetX: CGFloat = 0

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    
    // if zoom scale is 1.0
    //  don't allow horizontal scrolling
    if scrollView.zoomScale == 1.0 {
        scrollView.contentOffset.x = lastOffsetX
        return
    }
    
    // zoom scale is less than 1.0, so
    //  allow the scroll and update lastX
    lastOffsetX = scrollView.contentOffset.x
    
}

Here's a complete example you can try out:

class ExampleViewController: UIViewController, UIScrollViewDelegate {
    
    let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.backgroundColor = .systemYellow
        return v
    }()
    let contentView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemTeal
        return v
    }()
    let infoLabel: UILabel = {
        let v = UILabel()
        return v
    }()

    // we'll use this to track the current content X offset
    var lastOffsetX: CGFloat = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        scrollView.addSubview(contentView)
        view.addSubview(scrollView)
        view.addSubview(infoLabel)
        
        [contentView, scrollView, infoLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
        }
        
        // let's add a 8 x 8 "grid" of labels to the content view
        let outerVerticalStack = UIStackView()
        outerVerticalStack.axis = .vertical
        outerVerticalStack.spacing = 20
        outerVerticalStack.translatesAutoresizingMaskIntoConstraints = false
        
        contentView.addSubview(outerVerticalStack)
        
        var j: Int = 1
        for _ in 1...8 {
            let rowStack = UIStackView()
            rowStack.axis = .horizontal
            rowStack.spacing = 20
            rowStack.distribution = .fillEqually
            
            for _ in 1...8 {
                let v = UILabel()
                v.font = .systemFont(ofSize: 48.0, weight: .regular)
                v.text = "\(j)"
                v.textAlignment = .center
                v.backgroundColor = .green
                v.widthAnchor.constraint(equalToConstant: 240.0).isActive = true
                v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
                rowStack.addArrangedSubview(v)
                j  = 1
            }
            outerVerticalStack.addArrangedSubview(rowStack)
        }
        
        let safeG = view.safeAreaLayoutGuide
        let contentG = scrollView.contentLayoutGuide
        
        NSLayoutConstraint.activate([
            
            scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 20.0),
            scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
            scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
            scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -120.0),
            
            contentView.topAnchor.constraint(equalTo: contentG.topAnchor),
            contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
            
            outerVerticalStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20.0),
            outerVerticalStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20.0),
            outerVerticalStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20.0),
            outerVerticalStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.0),

            // put the info label below the scroll view
            infoLabel.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 20.0),
            infoLabel.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -20.0),
            
        ])

        // we'll update min zoom in viewDidAppear
        //  (after all views have been laid out)
        scrollView.minimumZoomScale = 1.0
        scrollView.maximumZoomScale = 1.0
        
        // we need to disable zoom bouncing, or
        //  we get really bad positioning effect
        //  when zooming in past 1.0
        scrollView.bouncesZoom = false
        
        // assign the delegate
        scrollView.delegate = self

        // update the info label
        updateInfo()

    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // update min zoom scale so we can only "zoom out" until
        //  the content view fits the scroll view frame
        if scrollView.minimumZoomScale == 1.0 {
            print(contentView.frame.size)
            let xScale = scrollView.frame.width / contentView.frame.width
            let yScale = scrollView.frame.height / contentView.frame.height
            scrollView.minimumZoomScale = min(xScale, yScale)
        }

    }
    
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return contentView
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        
        // if zoom scale is 1.0
        //  don't allow horizontal scrolling
        if scrollView.zoomScale == 1.0 && !scrollView.isZooming {
            scrollView.contentOffset.x = lastOffsetX
            return
        }
        
        // zoom scale is less than 1.0, so
        //  allow the scroll and update lastX
        lastOffsetX = scrollView.contentOffset.x
        
    }

    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        updateInfo()
    }
    
    func updateInfo() {
        let s = String(format: "%0.4f", scrollView.zoomScale)
        infoLabel.text = "Zoom Scale: \(s)"
    }
    
}
  •  Tags:  
  • Related