Ryan
Ryan

Reputation: 109

Find view in array of views that is closest to the center of scrollview

How can you find the view within an array of subviews that has the x coordinate closest to the center of the screen?

This is what I have:

extension UIScrollView {
    func scrollToView(view:UIView, animated: Bool) {
        if let origin = view.superview {
            let childStartPoint = origin.convert(view.frame.origin, to: self)
            self.scrollRectToVisible(CGRect(x: childStartPoint.x, y: 0, width: 1, height: self.frame.height), animated: animated)
        }
    }
}

Upvotes: 1

Views: 281

Answers (2)

DonMag
DonMag

Reputation: 77433

You can find the "closest to center" subview by getting the "virtual center" of the scroll view - that is, the .contentOffset.x plus 1/2 of the width of the frame:

    // get the "virtual center" of the scroll view
    let scrollCenterX = scrollView.contentOffset.x + scrollView.frame.width * 0.5
    

Then, find the "center" view by getting the minimum difference between the subviews centers and the "virtual center":

    // find the subview with center.x closest to scrollCenterX
    let closestToCenterView = viewsToTrack.min { a, b in abs(a.center.x - scrollCenterX) < abs(b.center.x - scrollCenterX) }
    

Here's a working example:

class CenterInScrollViewController: UIViewController {

    let scrollView: UIScrollView = {
        let v = UIScrollView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = .systemYellow
        return v
    }()
    
    var viewsToTrack: [UIView] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(scrollView)
        
        let g = view.safeAreaLayoutGuide

        NSLayoutConstraint.activate([
            
            scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            scrollView.heightAnchor.constraint(equalToConstant: 200.0),
            scrollView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            
        ])
        
        let cg = scrollView.contentLayoutGuide
        
        var prevView: UIView!
        
        for i in 1...10 {
            let v = UILabel()
            v.text = "\(i)"
            v.textAlignment = .center
            v.backgroundColor = .cyan
            v.layer.borderWidth = 1
            v.layer.borderColor = UIColor.red.cgColor
            v.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(v)
            v.widthAnchor.constraint(equalToConstant: 120.0).isActive = true
            v.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor).isActive = true
            if i == 1 {
                v.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 10.0).isActive = true
            } else {
                v.leadingAnchor.constraint(equalTo: prevView.trailingAnchor, constant: 40.0).isActive = true
            }
            if i == 10 {
                v.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -10.0).isActive = true
            }
            viewsToTrack.append(v)
            prevView = v
        }

        scrollView.delegate = self
        
    }

    func centerMiddleView() -> Void {
        // make sure we have views to find the centers
        guard viewsToTrack.count > 0 else {
            return
        }

        // reset view backgrounds to cyan
        viewsToTrack.forEach { $0.backgroundColor = .cyan }
        
        // get the "virtual center" of the scroll view
        let scrollCenterX = scrollView.contentOffset.x + scrollView.frame.width * 0.5
        
        // find the subview with center.x closest to scrollCenterX
        let closestToCenterView = viewsToTrack.min { a, b in abs(a.center.x - scrollCenterX) < abs(b.center.x - scrollCenterX) }
        
        // make sure we found one
        if let v = closestToCenterView {
            // set its background to yellow
            v.backgroundColor = .yellow
            // animate its center to the center of the scroll view via contentOffset
            UIView.animate(withDuration: 0.3, animations: {
                self.scrollView.contentOffset.x = v.center.x - self.scrollView.frame.width * 0.5
            })
        } else {
            print("No center view? This shouldn't happen...")
        }
    }
    
}

extension CenterInScrollViewController: UIScrollViewDelegate {
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        centerMiddleView()
    }
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        // let DidEndDecelerating handle positioning,
        //  unless Dragging ended while "holding in-place"
        if !decelerate {
            centerMiddleView()
        }
    }
}

Note that unless you've added enough Leading and Trailing from the first and last subview, you won't be able to center those two views.

Upvotes: 1

aheze
aheze

Reputation: 30268

You could probably make this more efficient with zip or something. But this is what I came up with:

func centerMiddleView() {
    var closestDistance = CGFloat(9999)
    var closestView = UIView()
    
    let scrollViewCenterX = scrollView.bounds.width / 2
    
    for colorView in colorViews { /// loop over views
        if
            let colorView = colorView,
            let convertedCenter = colorView.superview?.convert(colorView.center, to: nil).x /// the view's center in terms of the screen
        {
            
            let distance = scrollViewCenterX - convertedCenter
            if abs(distance) < closestDistance { /// this is closer than the previous view
                closestDistance = distance
                closestView = colorView
            }
        }
    }
    
    let closestViewCenter = closestView.center.x
    let adjustedCenter = closestViewCenter - scrollViewCenterX
    
    let offsetPoint = CGPoint(x: adjustedCenter, y: 0) /// ignore the Y coordinate
    scrollView.setContentOffset(offsetPoint, animated: true)
}

You can call this when the user's finger lifts (you need both functions to handle when they are scrolling slowly):

extension ViewController: UIScrollViewDelegate {
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        centerMiddleView()
    }
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        centerMiddleView()
    }
}

colorViews is an array of the views in the scrollview.

@IBOutlet weak var orangeView: UIView!
@IBOutlet weak var cherryView: UIView!
@IBOutlet weak var purpleView: UIView!
@IBOutlet weak var blueView: UIView!
@IBOutlet weak var greenView: UIView!

lazy var colorViews = [orangeView, cherryView, purpleView, blueView, greenView]

@IBOutlet weak var scrollView: UIScrollView!

override func viewDidLoad() {
    super.viewDidLoad()
    
    scrollView.delegate = self /// don't forget to set delegate
    scrollView.contentInset = UIEdgeInsets(top: 0, left: 200, bottom: 0, right: 200) /// prevent glitch when centering an view at the edge
}

Result:

Upvotes: 1

Related Questions