Issue in blocking Vertical scroll on UIScrollView in Swift 4.0

Viewed 1476

I have an Image carousel in my app I use a UIScrollView to show the images inside. everything works fine, it's just that I want to know how do I block up movements in the UIScrollView

I'm trying to block the vertical scroll by doing:

scrollView.showsVerticalScrollIndicator = false
scrollView.contentSize  = CGSize(width: scrollView.contentSize.width, height: 0) //disable vertical

everything in that works fine and it really blocks the vertical scroll

The problem is, that I also have a timer, that moves the UIScrollView programmatically by doing:

var frame: CGRect = scrollView.frame
frame.origin.x = frame.size.width * CGFloat(pageToMove)
frame.origin.y = -35
scrollView.scrollRectToVisible(frame, animated: true)

and once I block the vertical scroll, this function to scrollReactToVisible doesn't do anything. and I don't get any error for that.

is there a way currently to also block the scroll vertically (and allow to scroll right and left as usual) and also move the scrollview programmatically?

I'm attaching my full view controller:

class CaruselleScreenViewController: UIViewController, CaruselleScreenViewProtocol, UIScrollViewDelegate {

    var myPresenter: CaruselleScreenPresenterProtocol?

    @IBOutlet weak var pageControl: UIPageControl!
    @IBOutlet weak var scrollView: UIScrollView!

    var slides:[CaruselleTipsCard] = [];

    var timer:Timer?
    var currentPageMultiplayer = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        myPresenter = CaruselleScreenPresenter(controller: self)

        //initlizes view
        pageControl.numberOfPages = slides.count
        pageControl.currentPage = 0
        view.bringSubview(toFront: pageControl)

        //delegates
        scrollView.delegate = self

        ////blocks vertical movement
        scrollView.showsVerticalScrollIndicator = false
        //scrollView.contentSize  = CGSize(width: scrollView.contentSize.width, height: 0) //disable vertical
    }

    func scheduleTimer(_ timeInterval: TimeInterval){
        timer = Timer.scheduledTimer(timeInterval: timeInterval, target: self, selector: #selector(timerCall), userInfo: nil, repeats: false)
    }

    @objc func timerCall(){
        print("Timer executed")

        currentPageMultiplayer = currentPageMultiplayer + 1

        if (currentPageMultiplayer == 5) {
            currentPageMultiplayer = 0
        }
        pageControl.currentPage = currentPageMultiplayer
        scrollToPage(pageToMove: currentPageMultiplayer)

        scheduleTimer(5)
    }

    func scrollToPage(pageToMove: Int) {
        print ("new one")
        var frame: CGRect = scrollView.frame
        frame.origin.x = frame.size.width * CGFloat(pageToMove)
        frame.origin.y = -35
        scrollView.scrollRectToVisible(frame, animated: true)
    }

    func createSlides() -> [CaruselleTipsCard] {

        let slide1:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide1.mainPic.image = UIImage(named: "backlightingIllo")

        //
        let slide2:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide2.mainPic.image = UIImage(named: "comfortableIllo")

        //
        let slide3:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide3.mainPic.image = UIImage(named: "pharmacyIllo")

        //
        let slide4:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide4.mainPic.image = UIImage(named: "batteryIllo")

        //
        let slide5:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide5.mainPic.image = UIImage(named: "wiFiIllo")

        return [slide1, slide2, slide3, slide4, slide5]
    }

    func setupSlideScrollView(slides : [CaruselleTipsCard]) {
        scrollView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
        scrollView.contentSize = CGSize(width: view.frame.width * CGFloat(slides.count), height: view.frame.height)
        scrollView.isPagingEnabled = true

        for i in 0 ..< slides.count {
            slides[i].frame = CGRect(x: view.frame.width * CGFloat(i), y: 0, width: view.frame.width, height: view.frame.height)
            scrollView.addSubview(slides[i])
        }
    }


    //////

    /*
     * default function called when view is scrolled. In order to enable callback
     * when scrollview is scrolled, the below code needs to be called:
     * slideScrollView.delegate = self or
     */
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let pageIndex = round(scrollView.contentOffset.x/view.frame.width)
        pageControl.currentPage = Int(pageIndex)

        let maximumHorizontalOffset: CGFloat = scrollView.contentSize.width - scrollView.frame.width
        let currentHorizontalOffset: CGFloat = scrollView.contentOffset.x

        // vertical
        let maximumVerticalOffset: CGFloat = scrollView.contentSize.height - scrollView.frame.height
        let currentVerticalOffset: CGFloat = scrollView.contentOffset.y

        let percentageHorizontalOffset: CGFloat = currentHorizontalOffset / maximumHorizontalOffset
        let percentageVerticalOffset: CGFloat = currentVerticalOffset / maximumVerticalOffset


        /*
         * below code changes the background color of view on paging the scrollview
         */
        //        self.scrollView(scrollView, didScrollToPercentageOffset: percentageHorizontalOffset)


        /*
         * below code scales the imageview on paging the scrollview
         */
        let percentOffset: CGPoint = CGPoint(x: percentageHorizontalOffset, y: percentageVerticalOffset)

        if(percentOffset.x > 0 && percentOffset.x <= 0.25) {

            slides[0].mainPic.transform = CGAffineTransform(scaleX: (0.25-percentOffset.x)/0.25, y: (0.25-percentOffset.x)/0.25)
            slides[1].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.25, y: percentOffset.x/0.25)

        } else if(percentOffset.x > 0.25 && percentOffset.x <= 0.50) {
            slides[1].mainPic.transform = CGAffineTransform(scaleX: (0.50-percentOffset.x)/0.25, y: (0.50-percentOffset.x)/0.25)
            slides[2].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.50, y: percentOffset.x/0.50)

        } else if(percentOffset.x > 0.50 && percentOffset.x <= 0.75) {
            slides[2].mainPic.transform = CGAffineTransform(scaleX: (0.75-percentOffset.x)/0.25, y: (0.75-percentOffset.x)/0.25)
            slides[3].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.75, y: percentOffset.x/0.75)

        } else if(percentOffset.x > 0.75 && percentOffset.x <= 1) {
            slides[3].mainPic.transform = CGAffineTransform(scaleX: (1-percentOffset.x)/0.25, y: (1-percentOffset.x)/0.25)
            slides[4].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x, y: percentOffset.x)
        }
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "findingClinitionSugue" {
            let destination = segue.destination as! FirstAvailableSearchViewController
            //destination.consumer = consumer
        }

        if (timer != nil) {
            timer?.invalidate()
        }
    }

    // protocol functions

    func initlizeSlides() {
        slides = createSlides()
        setupSlideScrollView(slides: slides)
    }

    func initlizeTimer() {
        scheduleTimer(5)
    }
}
6 Answers

The problem might be about setting the contentSize height value to 0 initally, so even though timer wants scrollView to move, it cannot do that.

Can you try replacing this line:

scrollView.contentSize = CGSize(width: scrollView.contentSize.width, height: 0)

With:

scrollView.contentInsetAdjustmentBehavior = .never

Depending the application and functionality required within the scrollview - could you disable user interaction of the scrollview so it can still be moved programmatically?

That would just be

scrollView.isUserInteractionEnabled = false

This would of course depend on whether you need items in the scrollview to be interactive

Maybe you can subclass your UIScrollView, and override touchesBegan.

class CustomScrollView: UIScrollView {

    var touchesDisabled = false
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        if touchesDisabled {
            // here parse the touches, if they go in the horizontal direction, allow scrolling
            // set tolerance for vertical movement
            let tolerance: CGFloat = 5.0
            let variance = touches.reduce(0, { Yvariation, touch in
                Yvariation + abs(touch.location(in: view).y - touch.previousLocation(in: view).y)
            })
            if variance <= tolerance * CGFloat(touches.count) {
                let Xtravelled = touches.reduce(0, { Xstep, touch in
                    Xstep + (touch.location(in: view).x - touch.previousLocation(in: view).x)
                })
                // scroll horizontally by the x component of hand gesture
                var newFrame: CGRect = scrollView.frame
                newFrame.origin.x += Xtravelled
                self.scrollRectToVisible(frame, animated: true)
            }
        }

        else {
            super.touchesBegan(touches: touches, withEvent: event)
        }
    }
}

This way you can manually move the scrollview horizontally while disabling vertical movement when touchesDisabled is set true.

This turned out to be quite an interesting problem...

While it is easy to lock UIScrollView scrolling to one axis only using the UIScrollViewDelegate, it is impossible to provide smooth scrolling while changing the scrolling programmatically (as you do with the Timer) at the same time.

Below, you will find a DirectionLockingScrollView class I just wrote that should make things easier for you. It's a UIScrollView that you can initialize either programmatically, or via the Interface Builder.

It features isHorizontalScrollingEnabled and isVerticalScrollingEnabled properties.

HOW IT WORKS INTERNALLY

It adds a second "control" UIScrollView that is identical to the main DirectionLockingScrollView and propagates to it all pan events intended for the main scroll view. Every time the "control" scroll view's bounds change, the change is propagated to the main scroll view BUT x and y are altered (based on isHorizontalScrollingEnabled and isVerticalScrollingEnabled) to disable scrolling on the requested axis.

DirectionLockingScrollView.swift

/// `UIScrollView` subclass that supports disabling scrolling on any direction
/// while allowing the other direction to be changed programmatically (via
/// `setContentOffset(_:animated)` or `scrollRectToVisible(_:animated)` or changing the 
/// bounds etc.
///
/// Can be initialized programmatically or via the Interface Builder.
class DirectionLockingScrollView: UIScrollView {
    var isHorizontalScrollingEnabled = true
    var isVerticalScrollingEnabled = true

    /// The control scrollview is added below the `DirectionLockingScrollView`
    /// and is used to implement all native scrollview behaviours (such as bouncing)
    /// based on user input.
    ///
    /// It is required to be able to change the bounds of the `DirectionLockingScrollView`
    /// while maintaining scrolling in only one direction and allowing for setting the contentOffset
    /// (changing scrolling for any axis - even the disabled ones) programmatically.
    private let _controlScrollView = UIScrollView(frame: .zero)

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        installCustomScrollView()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        installCustomScrollView()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        updateCustomScrollViewFrame()
    }

    override func didMoveToSuperview() {
        super.didMoveToSuperview()
        guard let superview = superview else {
            _controlScrollView.removeFromSuperview()
            return
        }

        superview.insertSubview(_controlScrollView, belowSubview: self)
        updateCustomScrollViewFrame()
    }

    // MARK: - UIEvent propagation

    func viewIgnoresEvents(_ view: UIView?) -> Bool {
        let viewIgnoresEvents =
            view == nil ||
            view == self ||
            !view!.isUserInteractionEnabled ||
            !(view is UIControl && (view!.gestureRecognizers ?? []).count == 0)

        return viewIgnoresEvents
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        if viewIgnoresEvents(view) {
            return _controlScrollView
        }

        return view
    }

    // MARK: - Main scrollview settings propagation to `controlScrollView`

    override var contentInset: UIEdgeInsets {
        didSet {
            _controlScrollView.contentInset = contentInset
        }
    }

    override var contentScaleFactor: CGFloat {
        didSet {
            _controlScrollView.contentScaleFactor = contentScaleFactor
        }
    }

    override var contentSize: CGSize {
        didSet {
            _controlScrollView.contentSize = contentSize
        }
    }

    override var bounces: Bool {
        didSet {
            _controlScrollView.bounces = bounces
        }
    }

    override var bouncesZoom: Bool {
        didSet {
            _controlScrollView.bouncesZoom = bouncesZoom
        }
    }
}

extension DirectionLockingScrollView: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        updateBoundsFromCustomScrollView(scrollView)
    }
}

private extension DirectionLockingScrollView {
    /// Propagates `controlScrollView` bounds to the actual scrollview.
    /// - Parameter scrollView: If the scrollview provided is not the `controlScrollView`
    //                          the main scrollview bounds are not updated.
    func updateBoundsFromCustomScrollView(_ scrollView: UIScrollView) {
        if scrollView != _controlScrollView {
            return
        }

        var newBounds = scrollView.bounds.origin
        if !isHorizontalScrollingEnabled {
            newBounds.x = self.contentOffset.x
        }

        if !isVerticalScrollingEnabled {
            newBounds.y = self.contentOffset.y
        }

        bounds.origin = newBounds
    }

    func installCustomScrollView() {
        _controlScrollView.delegate = self
        _controlScrollView.contentSize = contentSize
        _controlScrollView.showsVerticalScrollIndicator = false
        _controlScrollView.showsHorizontalScrollIndicator = false

        // The panGestureRecognizer is removed because pan gestures might be triggered
        // on subviews of the scrollview which do not ignore touch events (determined
        // by `viewIgnoresEvents(_ view: UIView?)`). This can happen for example
        // if you tap and drag on a button inside the scroll view.    
        removeGestureRecognizer(panGestureRecognizer)
    }

    func updateCustomScrollViewFrame() {
        if _controlScrollView.frame == frame { return }
        _controlScrollView.frame = frame
    }
}

USAGE

After you've included the above class in your app, don't forget to change your scroll view's class to DirectionLockingScrollView in your .xib or .storyboard.

Then update your code as below (only two lines changed, marked with // *****).

class CaruselleScreenViewController: UIViewController, CaruselleScreenViewProtocol, UIScrollViewDelegate {
    var myPresenter: CaruselleScreenPresenterProtocol?

    @IBOutlet weak var pageControl: UIPageControl! 
    @IBOutlet weak var scrollView: DirectionLockingScrollView! // *****

    var slides:[CaruselleTipsCard] = [];


    var timer:Timer?
    var currentPageMultiplayer = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        myPresenter = CaruselleScreenPresenter(controller: self)

        //initlizes view
        pageControl.numberOfPages = slides.count
        pageControl.currentPage = 0
        view.bringSubview(toFront: pageControl)

        scrollView.isHorizontalScrollingEnabled = false // *****

        //delegates
        scrollView.delegate = self

        ////blocks vertical movement
        scrollView.showsVerticalScrollIndicator = false
        //scrollView.contentSize  = CGSize(width: scrollView.contentSize.width, height: 0) //disable vertical
    }

    func scheduleTimer(_ timeInterval: TimeInterval){
        timer = Timer.scheduledTimer(timeInterval: timeInterval, target: self, selector: #selector(timerCall), userInfo: nil, repeats: false)
    }

    @objc func timerCall(){
        print("Timer executed")

        currentPageMultiplayer = currentPageMultiplayer + 1

        if (currentPageMultiplayer == 5) {
            currentPageMultiplayer = 0
        }
        pageControl.currentPage = currentPageMultiplayer
        scrollToPage(pageToMove: currentPageMultiplayer)

        scheduleTimer(5)
    }

    func scrollToPage(pageToMove: Int) {
        print ("new one")
        var frame: CGRect = scrollView.frame
        frame.origin.x = frame.size.width * CGFloat(pageToMove)
        frame.origin.y = -35
        scrollView.scrollRectToVisible(frame, animated: true)
    }


    func createSlides() -> [CaruselleTipsCard] {

        let slide1:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide1.mainPic.image = UIImage(named: "backlightingIllo")

        //
        let slide2:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide2.mainPic.image = UIImage(named: "comfortableIllo")

        //
        let slide3:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide3.mainPic.image = UIImage(named: "pharmacyIllo")

        //
        let slide4:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide4.mainPic.image = UIImage(named: "batteryIllo")

        //
        let slide5:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
        slide5.mainPic.image = UIImage(named: "wiFiIllo")

        return [slide1, slide2, slide3, slide4, slide5]
    }

    func setupSlideScrollView(slides : [CaruselleTipsCard]) {
        scrollView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
        scrollView.contentSize = CGSize(width: view.frame.width * CGFloat(slides.count), height: view.frame.height)
        scrollView.isPagingEnabled = true

        for i in 0 ..< slides.count {
            slides[i].frame = CGRect(x: view.frame.width * CGFloat(i), y: 0, width: view.frame.width, height: view.frame.height)
            scrollView.addSubview(slides[i])
        }
    }


    //////

    /*
     * default function called when view is scrolled. In order to enable callback
     * when scrollview is scrolled, the below code needs to be called:
     * slideScrollView.delegate = self or
     */
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let pageIndex = round(scrollView.contentOffset.x/view.frame.width)
        pageControl.currentPage = Int(pageIndex)

        let maximumHorizontalOffset: CGFloat = scrollView.contentSize.width - scrollView.frame.width
        let currentHorizontalOffset: CGFloat = scrollView.contentOffset.x

        // vertical
        let maximumVerticalOffset: CGFloat = scrollView.contentSize.height - scrollView.frame.height
        let currentVerticalOffset: CGFloat = scrollView.contentOffset.y

        let percentageHorizontalOffset: CGFloat = currentHorizontalOffset / maximumHorizontalOffset
        let percentageVerticalOffset: CGFloat = currentVerticalOffset / maximumVerticalOffset


        /*
         * below code changes the background color of view on paging the scrollview
         */
        //        self.scrollView(scrollView, didScrollToPercentageOffset: percentageHorizontalOffset)


        /*
         * below code scales the imageview on paging the scrollview
         */
        let percentOffset: CGPoint = CGPoint(x: percentageHorizontalOffset, y: percentageVerticalOffset)

        if(percentOffset.x > 0 && percentOffset.x <= 0.25) {

            slides[0].mainPic.transform = CGAffineTransform(scaleX: (0.25-percentOffset.x)/0.25, y: (0.25-percentOffset.x)/0.25)
            slides[1].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.25, y: percentOffset.x/0.25)

        } else if(percentOffset.x > 0.25 && percentOffset.x <= 0.50) {
            slides[1].mainPic.transform = CGAffineTransform(scaleX: (0.50-percentOffset.x)/0.25, y: (0.50-percentOffset.x)/0.25)
            slides[2].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.50, y: percentOffset.x/0.50)

        } else if(percentOffset.x > 0.50 && percentOffset.x <= 0.75) {
            slides[2].mainPic.transform = CGAffineTransform(scaleX: (0.75-percentOffset.x)/0.25, y: (0.75-percentOffset.x)/0.25)
            slides[3].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.75, y: percentOffset.x/0.75)

        } else if(percentOffset.x > 0.75 && percentOffset.x <= 1) {
            slides[3].mainPic.transform = CGAffineTransform(scaleX: (1-percentOffset.x)/0.25, y: (1-percentOffset.x)/0.25)
            slides[4].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x, y: percentOffset.x)
        }
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "findingClinitionSugue" {
            let destination = segue.destination as! FirstAvailableSearchViewController
            //destination.consumer = consumer
        }

        if (timer != nil) {
            timer?.invalidate()
        }
    }

    // protocol functions

    func initlizeSlides() {
        slides = createSlides()
        setupSlideScrollView(slides: slides)
    }

    func initlizeTimer() {
        scheduleTimer(5)
    }
}

If I've understood you problem well, you can stop scrolling whenever you want with this

scrollView.isScrollEnabled = false

Using UIScrollViewDelegate (or KVO on scrollView's contentOffset), you can just counteract any vertical movement in the carousel. Something like this:

var oldYOffset: CGFloat ....

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let deltaY = oldYOffset - scrollView.contentOffset.y
    oldYOffset = scrollView.contentOffset.y
    scrollView.contentOffset.y -= deltaY
}

This offset change will not be visible to the user. You could even use this to increase the speed of the scrolling, invert the scrolling (pan left and scrollView scrolls right), or entirely lock the motion of the scrollView without touching isScrollEnabled, contentSize, etc.

Related