Search code examples
iosswiftuiscrollviewuiscrollviewdelegate

How to get the view which is centered(or visible to user) inside UIScrollview?


I have a UIScrollview with horizontal pagination, inside which there are five different view. These are scrolling perfectly as required. And now i also have five buttons on the top of my screen, on button action the scrollview will scroll to the required page(for example if user tap on button 3, the scrollview will scroll to third page). So now I want a small view to work as a selectorView(that is if user scrolling to next page the selector view should move to next button during scroll)just below the five buttons. This is also working fine to some extent but there is small issue specially in iPad devices. The issue is that my selectorView is not finishing in the center of required button. How can it be in the center of required button. I have used below code to move the selectorView.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let newxPosition = scrollView.contentOffset.x
    if UIDevice().userInterfaceIdiom == .phone {
        UIView.animate(withDuration: 0.1) { [self] in
            self.movingSelectorView.frame.origin.x = newxPosition/5 + (movingViewXConstant ?? 25)
        }
    } else if UIDevice().userInterfaceIdiom == .pad {
        UIView.animate(withDuration: 0.1) { [self] in
            self.movingSelectorView.frame.origin.x = newxPosition/5 + (movingViewXConstant ?? 75)
        }
    }
}

enter image description here

Please see the image uploaded for better understanding. The five buttons are not in the scrollview as well as the movingSelectorView is also not inside the scrollView.


Solution

  • Without getting details from you on your view hierarchy and current code, I'll guess at something that you may find useful.

    Let's:

    • put the "Day" buttons in a horizontal stack view, using Fill Equally
    • add that stack view as a subview of the main view
    • add the "selector" view as subview of the main view
    • add a scroll view with 5 "full width" views and paging enabled

    We'll set the initial frame of the selector view to a size of (20, 3), and position it under the title label of the "Day" buttons.

    The center.x value of the selector view frame will start at one-half of the width of the first Day button.

    When the scroll view scrolls - whether being dragged or by calling .scrollRectToVisible on a button tap - we'll get the percentage it has scrolled, and update the center.x of the selector view to the same percentage of the total buttons width, offset by the "one-half button width" value.

    So, example code:

    class ViewController: UIViewController, UIScrollViewDelegate {
        
        let scrollView = UIScrollView()
        let btnStack = UIStackView()
        let movingSelectorView = UIView()
    
        // this will be one-half of the width of a "Day" button
        var btnCenterOffset: CGFloat = 0
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // stack view to hold the "Day" buttons
            btnStack.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(btnStack)
            
            btnStack.distribution = .fillEqually
    
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(scrollView)
    
            scrollView.isPagingEnabled = true
            
            // stack view to hold the 5 "Day" views in the scroll view
            let contentStackView = UIStackView()
            contentStackView.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(contentStackView)
            
            let g = view.safeAreaLayoutGuide
            let cg = scrollView.contentLayoutGuide
            let fg = scrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
    
                btnStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                btnStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                btnStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                btnStack.heightAnchor.constraint(equalToConstant: 50.0),
    
                scrollView.topAnchor.constraint(equalTo: btnStack.bottomAnchor, constant: 0.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
    
                contentStackView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
                contentStackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
                contentStackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
                contentStackView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
                
                contentStackView.heightAnchor.constraint(equalTo: fg.heightAnchor),
                
            ])
            
            // create 5 "Day" buttons and add them to the btnStack view
            for i in 1...5 {
                let b = UIButton()
                b.setTitle("Day \(i)", for: [])
                b.setTitleColor(.darkGray, for: .normal)
                b.setTitleColor(.lightGray, for: .highlighted)
                b.titleLabel?.font = .systemFont(ofSize: 14.0, weight: .bold)
                b.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
                btnStack.addArrangedSubview(b)
            }
            
            // create 5 views for the scroll view
            let colors: [UIColor] = [
                .yellow, .green, .systemBlue, .cyan, .systemYellow,
            ]
            for (i, c) in colors.enumerated() {
                let v = UILabel()
                v.font = .systemFont(ofSize: 80.0, weight: .regular)
                v.text = "\(i + 1)"
                v.textAlignment = .center
                v.backgroundColor = c
                contentStackView.addArrangedSubview(v)
                v.widthAnchor.constraint(equalTo: fg.widthAnchor).isActive = true
            }
    
            // movingSelectorView will partially cover the "Day" buttons frames, so
            //  don't let it capture touches
            movingSelectorView.isUserInteractionEnabled = false
            movingSelectorView.backgroundColor = .darkGray
            view.addSubview(movingSelectorView)
            
            scrollView.delegate = self
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            if let b = btnStack.arrangedSubviews.first {
                // set the frame size and initial position of the movingSelectorView
                movingSelectorView.frame = .init(x: 0.0, y: btnStack.frame.maxY - 12.0, width: 20.0, height: 3.0)
                // set btnCenterOffset to one-half the width of a "Day" button
                btnCenterOffset = b.frame.width * 0.5
                // move the selector view to the center of the first "Day" button
                movingSelectorView.center.x = btnCenterOffset + btnStack.frame.origin.x
            }
        }
        
        @objc func btnTapped(_ sender: UIButton) {
            guard let idx = btnStack.arrangedSubviews.firstIndex(of: sender) else { return }
            // "Day" button was tapped, so scroll the scroll view to the associated view
            let w: CGFloat = scrollView.frame.width
            let h: CGFloat = scrollView.frame.height
            let r: CGRect = .init(x: CGFloat(idx) * w, y: 0, width: w, height: h)
            scrollView.scrollRectToVisible(r, animated: true)
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            // get the percentage that the scroll view has been scrolled
            //  based on its total contentSize.width
            let pct = scrollView.contentOffset.x / scrollView.contentSize.width
            // move the selector view based on that percentage
            movingSelectorView.center.x = btnStack.frame.width * pct + btnCenterOffset + btnStack.frame.origin.x
        }
        
    }
    

    and how it looks when running:

    enter image description here

    This will work - with Zero code changes - independent of device / view size:

    enter image description here

    Note: This is EXAMPLE CODE ONLY!!! -- it is meant to help you get started, and is not intended to be "Production Ready"